File Selection Component
Imagine we need to implement a file picker for our application. Fortunately, the browser handles much of the underlying complexity, but we still need some custom logic to leverage the native file input and style it according to our needs. A basic implementation might look something like this.
Initially, this solution works well. We have a straightforward file selection mechanism, and users can choose files as needed. However, as other teams adopt this component, they’ll naturally want to tailor the UI to their specific requirements. For instance, suppose our team uses a specific brand color, but other teams need different color options. This isn’t a major issue—we can introduce an @Input() property to manage the button color.
`
<button (click)="openFileSelectDialog()" [ngClass]="color">
Pick a file
</button>
`;
export class FileSelectComponent {
@Input() color = 'primary';
}
@Input for button color control
Our component now has a bit more complexity, but it still functions correctly and supports various brand colors. At this stage, it remains relatively simple, but more feature requests are coming.
Next, a colleague notices this file selection interaction and wants to trigger the file picker using their custom <cool-button> component instead of a standard button. We could duplicate the logic that programmatically clicks the hidden input, but copying and pasting code, especially within the same component, feels wrong. So, we add another @Input() to specify which UI element should open the file dialog.
`
<button
*ngIf="!useCoolButton"
(click)="openFileSelectDialog()"
[ngClass]="color"
>
Pick a file
</button>
<cool-button
*ngIf="useCoolButton"
(click)="openFileSelectDialog()"
>
Pick a cool file
</cool-button>
`;
export class FileSelectComponent {
@Input() useCoolButton = false;
}
@Input to select the triggering button
At this point, the component is beginning to take on too many responsibilities, but it still accomplishes its purpose.
Now, another requirement comes in: displaying a list of selected files. Implementing this would involve adding markup for the list and yet another @Input() to toggle its visibility. Clearly, it’s time to pause and reconsider our approach. Ideally, we’d find a way to accommodate everyone’s needs without maintaining their specific UI preferences ourselves.
The Challenge of Customization
This example is somewhat simplified—file selection doesn’t have endless variations—but it illustrates the core problem headless components aim to solve. Many of us have encountered or written code like this. Whether it’s a universal feature or something app-specific, we often try to handle every possible customization in one component. So what’s wrong with this approach?
First, we don’t want to include code for features we never use. Even if we never touch certain variations, their code still gets bundled into our application. Managing a component with all conceivable use cases in one file also becomes unwieldy. As code evolves, having unrelated UI pieces mixed together increases the risk of breaking someone else’s implementation with an innocent change. Furthermore, as more variations pile on, the file grows longer, making it harder to read and maintain.
But perhaps we overcomplicated this. What if we just let users apply their own “theme” by overriding the default CSS?
This has the same fundamental issue: we’d still ship default styles that might be overridden. If teams already have their own design systems, duplicating styles is wasteful. Additionally, CSS overrides can’t change the underlying markup. Some UI modifications require entirely different HTML, which CSS alone cannot achieve.
So how can we provide the native file selection behavior while letting developers bring their own UI?
Headless File Selection Component
Angular offers more than just @Input() for component customization. Refactored as a headless component, here’s how the file selector looks now.
Let’s examine the code to understand how this works.
CallbackTemplateDirective
The first thing to notice is the *callbackTemplate directive.
<button
*callbackTemplate="let context"
class="primary"
(click)="context.openFileSelectDialog()"
>
pick a file
</button>
the *callbackTemplate directive in action
I usually rename this directive to something more domain-specific, but for clarity, I’ll keep it as callbackTemplate. (We’ll soon see why it resembles a callback function.) You can name it anything that fits your needs. The asterisk indicates this is a structural directive. Structural directives control whether the element they’re attached to gets rendered. This works similarly to *ngIf. Internally, Angular wraps the host element in an <ng-template> and passes it to the directive as a TemplateRef, which the directive can then render.
But let’s look at the CallbackTemplateDirective class itself.
constructor(
public template: TemplateRef<{ $implicit: TImplicitContext }>
) {}
CallbackTemplateDirective class
There’s minimal logic here—just a constructor with an injected TemplateRef. So who actually renders the template? Notice that the access modifier is set to public…
FileSelectComponent
The real work happens in the FileSelectComponent. First, look at the @ContentChild decorator.
@ContentChild(CallbackTemplateDirective)
callback: CallbackTemplateDirective;
FileSelectComponent.callback
This special decorator instructs Angular to find the first instance of CallbackTemplateDirective among its content children. Understanding content children: these are elements, components, or directives placed between the parent component’s opening and closing tags. The @ContentChild decorator acts like Angular’s version of querySelector, except it can search for component and directive instances in addition to native elements.
Once we have the callbackTemplate directive, we can access its injected TemplateRef since we’ve made it public. The FileSelectComponent can then render callback.template using [ngTemplateOutlet](https://angular.io/api/common/NgTemplateOutlet).
<ng-container
[ngTemplateOutlet]="callback.template"
[ngTemplateOutletContext]="templateContext"
></ng-container>
rendering callback.template
What’s elegant here is that FileSelectComponent doesn’t need to know what it’s rendering. It simply has a template and knows where to place it. The component consumer decides what gets rendered. This separation of concerns lets us use any UI to trigger the file picker.
But how does the custom UI open the dialog? When rendering a template, we can pass context data using [ngTemplateOutletContext]="templateContext".
templateContext = {
$implicit: {
// this has to be a lambda or else we get `this` problems
openFileSelectDialog: () => this.openFileSelectDialog(),
},
};
context object for rendering callback.template
The $implicit key in the context might seem confusing at first. Its value gets passed to the template input variable let context. We could add more keys to the context object, but that would require more syntax in the template. For simplicity, I prefer putting data into $implicit because we can name our template context variable anything we like.
<button
*callbackTemplate="let context"
class="primary"
(click)="context.openFileSelectDialog()"
>
pick a file
</button>
using the context in the template
When *callbackTemplate is rendered, context gets populated with the content of templateContext.$implicit.
Now that the parent <file-select> component renders the TemplateRef from callbackTemplate and exposes the method to open the file dialog, any child content can trigger the picker from whichever UI element it chooses. From Isaac and Stephen’s examples mentioned earlier, we see that <ng-template> can be used directly instead of a structural directive, though I prefer the structural directive syntax. Both approaches use the same underlying Angular features—just different syntax.
Closing Thoughts
Adopting this component style is definitely a mindset shift, but I hope you see the benefits of sharing UI behavior without cluttering your code or dictating a specific look. In Angular, we typically rely on @Input() and @Output() for component communication, but as demonstrated here, other patterns can create more flexible and expressive component APIs.
I’ll leave you with a final, self-guided example. It employs the same pattern to simplify modal creation and opening, which is often tricky with most Angular libraries. Both the file selector and modal examples come from production code I’ve written. My colleagues have also grown to appreciate this approach’s simplicity. As the modal example shows, the parent component may render some basic UI, so it’s not strictly “headless.” When designing your component APIs, you decide where to draw the line between implementation details and customization based on your app’s needs. A specialized headless component might only allow limited customization, while a more generic one might render nothing at all, enabling complete flexibility.
