Skip to content

Attribute Passthrough

A UI kit can’t anticipate every attribute its users will need. Rather than adding a C# property for each of placeholder, autocomplete, aria-describedby, data-*, x-on:input and so on, let them pass straight through.

The examples below carry on from Headless Components, where PinesInput is a component inheriting StaticInput.

Every component inheriting StaticInputBase exposes an AdditionalAttributes collection holding whatever the consumer wrote that wasn’t bound to one of its properties. Apply it to an element in your template with static-attributes:

@model PinesInput
<input static-for="@Model.InputExpression"
static-attributes="@Model.AdditionalAttributes"
placeholder="Type something"
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" />
@* consumer view *@
<pines-input asp-for="Form.Email"
class="mt-4"
placeholder="you@example.com"
autocomplete="email"
data-testid="email"
required />
<!-- rendered -->
<input placeholder="you@example.com"
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm mt-4"
autocomplete="email" data-testid="email" required
type="email" name="Email" id="Email"
data-val="true" data-val-email="…" />

Note that the consumer’s placeholder replaced the template’s, while their class was appended to it.

AttributeBehavior
classMerged. Your classes first, the consumer’s appended, duplicates dropped.
class-replaceReplaces. Discards your class entirely. Using it with class throws.
styleMerged, joined with ;. Inline styles are last-declaration-wins, so the consumer’s values take effect.
typeNever passed through. Owned by the static-* tag helpers.
name, id, data-val-*Owned by static-for. A consumer can’t break model binding from the outside.
everything elseConsumer wins. Attributes in your template act as defaults they can override.

Merging works cleanly when the consumer is adding something — a margin, a grid placement. It breaks down when they’re trying to override one of your classes.

Say PinesInput sets a full-width field and a consumer wants a narrow one:

<pines-input asp-for="Form.Zip" class="w-32" />
<!-- rendered: both are on the element -->
<input class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm w-32" … />

This is a common problem with Tailwind. The input will still render at full width because w-32 doesn’t override the existing w-full.

class-replace is the escape hatch. It discards your classes entirely, so there is nothing left to conflict with:

<pines-input asp-for="Form.Zip" class-replace="w-32 rounded-md border px-3 py-2" />
<!-- rendered: only the consumer's classes -->
<input class="w-32 rounded-md border px-3 py-2" … />

That gives up every base style your component defines — note that the consumer had to restate rounded-md, border and the padding to keep them. Reach for it only when a merge genuinely can’t express the override.

Most components render more than a bare <input> — usually a wrapper holding the label, the field and a validation message. When that’s the case, not every attribute a consumer passes belongs on the same element, so AdditionalAttributes is immutable and its filters return new collections you can point at different elements.

The attributes that most often need this treatment are the ones from AlpineJS and HTMX, the two libraries the HATS stack is built around. Both add behaviour to a page by writing HTML attributes instead of JavaScript:

PrefixLibraryWhat it does
x-AlpineJSDeclares client-side state and reactivity. x-show="open" shows the element while open is true; x-model="email" two-way binds an input’s value to a piece of state.
hx-HTMXTurns an element into a request. hx-get="/search" fetches that URL and swaps the response into the page, with hx-trigger and hx-target controlling when and where.

Both are identified by their prefix, which makes the split easy: send everything prefixed x- or hx- to the wrapper, and everything else to the input. That’s what WithPrefix and WithoutPrefix are for.

@model PinesInput
<div class="relative" static-attributes="@Model.AdditionalAttributes.WithPrefix("x-", "hx-")">
<label static-for="@Model.InputExpression" class="block text-sm font-medium"></label>
<input static-for="@Model.InputExpression"
static-attributes="@Model.AdditionalAttributes.WithoutPrefix("x-", "hx-")"
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" />
</div>
@* consumer view *@
<pines-input asp-for="Form.Email"
x-show="wantsUpdates"
hx-target="#email-feedback"
placeholder="you@example.com" />

x-show and hx-target land on the <div>, placeholder on the <input>.

The wrapper is the right home for both. x-show there hides the label along with the field instead of leaving a dangling label behind, and HTMX inherits most of its attributes from ancestor elements, so a request declared on the wrapper still governs the input inside it.

Only and Except filter by exact name instead of prefix, for the occasional attribute that needs different placement than its prefix suggests — x-model, for one, two-way binds an input’s value and only works on the <input> itself. See StaticAttributeCollection for the full set of filters.