Skip to content

Headless Components

Headless components are abstract base classes shipped with Static Components that give you the scaffolding needed to build your own input, select, label and button components for a UI kit — without having to wire up name, id, the bound value and the data-val-* validation attributes yourself.

You inherit from one of them, write your Razor template once, and end users of your kit can drop the component into a form with asp-for just like they would with the built-in inputs.

public class PinesCheckbox : StaticCheckbox { }
@using TechGems.PinesUI.Views.Components.PinesCheckbox
@model PinesCheckbox
<div class="flex items-center mb-4">
<input static-checkbox-for="@Model.InputExpression"
checked="@Model.Checked"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-neutral-900 focus:ring-neutral-900" />
<label static-for="@Model.InputExpression"
class="ml-2 text-sm font-medium text-gray-900"></label>
</div>
<!-- consumer view -->
<pines-checkbox asp-for="Form.AcceptsTerms"></pines-checkbox>

You might have noticed that instead of asp-for, the inner inputs use static-* tag helpers. The next section explains why the static-* tag helpers exist in the first place.

When you use asp-for on the outer custom tag (<pines-checkbox asp-for="…">), ASP.NET Core resolves the expression once and assigns the resulting ModelExpression to your InputExpression property. Inside the component’s template you have to pass that ModelExpression to the real <input> so the input knows what to bind.

But if you write asp-for="@Model.InputExpression" on that inner input, the built-in asp-for tag helper resolves the expression a second time. The property it sees is now of type ModelExpression, not bool / string / int, so the metadata it reads (model type, validation attributes, display name, required-ness) is the metadata of ModelExpression itself — not of the property the consumer originally targeted. The bound name, id and data-val-* attributes all come out wrong.

The static-for, static-checkbox-for and static-radio-for tag helpers solve this by expecting a doubly-wrapped ModelExpression. They reach one layer deeper (InputExpression.ModelExplorer.Model is itself a ModelExpression), pick up the real property’s metadata from there, and emit name, id, value and the full set of data-val-* attributes against the original property.

ClassPurposeMaps asp-for to
StaticInputBaseRoot for any input-like component. Exposes InputExpression, ShowLabel, Disabled.InputExpression
StaticInputFree-text / number / date / email / password / etc. inputs.InputExpression
StaticCheckboxCheckbox inputs. Adds Value (the checked value) and Checked (an explicit override).InputExpression
StaticRadioRadio inputs. Adds Value (the option this radio represents — required).InputExpression
StaticSelect<select> elements. Adds Items mapped from asp-items.InputExpression
StaticLabelStandalone <label> components.For
StaticButton<button> components. Validates the type attribute (button / submit / reset) and exposes Disabled.

You’ll use one of these inside the headless component’s template:

Tag helperTarget elementWhat it does
static-for<input>Sets type (inferred from the model property or overridden via type="…"), name, id, value and the data-val-* attributes. Does not support checkbox or radio inputs.
static-checkbox-for<input>Always emits type="checkbox", value="true", a paired hidden value="false" so unchecked submits, and checked based on the bool model value. Only bool is allowed as the inner model type.
static-radio-for<input>Emits type="radio". Requires an explicit value="…" (the option the radio represents) and adds checked when the bound model value matches it.
static-for<label>Sets for from the inner expression’s name and uses the inner property’s [DisplayName] (or its property name as a fallback) as the label text.
static-for<textarea>Same as static-for on <input>, but only string is supported.
static-for + static-items<select>Renders <option> and <optgroup> tags. static-items accepts an IEnumerable<SelectListItem> just like asp-items. Selection prefers the bound model value over each item’s Selected flag.

All of these read the original property’s [Required], [StringLength], [Range], [EmailAddress], [Phone], [Url], [RegularExpression] and [DisplayName] attributes and emit the matching data-val-* attributes — so unobtrusive jQuery validation (or any consumer of that contract) just works.

Inherit from StaticCheckbox. You don’t need any logic in the class — the base gives you InputExpression, ShowLabel, Disabled, Value and Checked for free.

~/Views/Components/PinesCheckbox.cshtml.cs
using TechGems.StaticComponents.Headless;
namespace TechGems.PinesUI.Views.Components;
public class PinesCheckbox : StaticCheckbox { }
@* ~/Views/Components/PinesCheckbox.cshtml *@
@using TechGems.PinesUI.Views.Components.PinesCheckbox
@model PinesCheckbox
<div class="flex items-center mb-4">
<input static-checkbox-for="@Model.InputExpression"
checked="@Model.Checked"
disabled="@(Model.Disabled ? "disabled" : null)"
class="w-4 h-4 bg-gray-100 border-gray-300 rounded text-neutral-900 focus:ring-neutral-900" />
@if (Model.ShowLabel)
{
<label static-for="@Model.InputExpression"
class="ml-2 text-sm font-medium text-gray-900"></label>
}
</div>

Consumers use it the same way they’d use a built-in checkbox:

<pines-checkbox asp-for="Form.AcceptsTerms"></pines-checkbox>
<pines-checkbox asp-for="Form.SendNewsletter" show-label="false"></pines-checkbox>

The rendered HTML carries the bound name, the sanitized id, a paired hidden input so the form posts a value even when unchecked, and data-val-* from any validation attributes on AcceptsTerms / SendNewsletter.

Inherit from StaticInput. You can pass an explicit type through to static-for if you want to lock the input to a specific HTML type; otherwise the type is inferred from the model property’s CLR type and [DataType] attribute.

~/Views/Components/PinesInput.cshtml.cs
public class PinesInput : StaticInput { }
@* ~/Views/Components/PinesInput.cshtml *@
@model PinesInput
@if (Model.ShowLabel)
{
<label static-for="@Model.InputExpression"
class="block text-sm font-medium text-gray-900"></label>
}
<input static-for="@Model.InputExpression"
disabled="@(Model.Disabled ? "disabled" : null)"
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" />
<pines-input asp-for="Form.FullName"></pines-input>
<pines-input asp-for="Form.SignupEmail"></pines-input> @* [DataType(EmailAddress)] → type="email" *@
<pines-input asp-for="Form.Password" type="password"></pines-input>

Radios are different from checkboxes in that one component instance represents a single option — so the Value property is mandatory and the checked state is derived by comparing it to the bound model value.

public class PinesRadio : StaticRadio { }
@model PinesRadio
<label class="flex items-center gap-2">
<input static-radio-for="@Model.InputExpression"
value="@Model.Value"
disabled="@(Model.Disabled ? "disabled" : null)"
class="h-4 w-4 border-gray-300 text-neutral-900" />
@if (Model.ShowLabel)
{
<span class="text-sm">@Model.Value</span>
}
</label>
<pines-radio asp-for="Form.Plan" value="basic"></pines-radio>
<pines-radio asp-for="Form.Plan" value="pro"></pines-radio>
<pines-radio asp-for="Form.Plan" value="enterprise"></pines-radio>

StaticSelect adds an Items property that’s mapped from asp-items — so consumers pass SelectListItems exactly as they would to a native <select>.

public class PinesSelect : StaticSelect { }
@model PinesSelect
@if (Model.ShowLabel)
{
<label static-for="@Model.InputExpression"
class="block text-sm font-medium text-gray-900"></label>
}
<select static-for="@Model.InputExpression"
static-items="@Model.Items"
disabled="@(Model.Disabled ? "disabled" : null)"
class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
</select>
<pines-select asp-for="Form.Department"
asp-items="@Model.DepartmentOptions"></pines-select>

SelectListItems that share a SelectListGroup reference are rendered together inside a single <optgroup> at the position of the group’s first occurrence — matching the layout the built-in select tag helper produces.

A standalone <label> component is useful when you want kit-specific label styling that can sit next to any input.

public class PinesLabel : StaticLabel { }
@model PinesLabel
<label static-for="@Model.For"
class="block text-sm font-medium text-gray-900"></label>

Note that StaticLabel exposes the bound expression as For instead of InputExpression, but asp-for on the consumer-facing tag still maps to it.

<pines-label asp-for="Form.FullName"></pines-label>

The label content comes from the property’s [DisplayName] if present, otherwise the property name itself.

StaticButton inherits from StaticComponent (not StaticInputBase) since buttons don’t bind to a model. It adds a validated type attribute and a Disabled flag.

public class PinesButton : StaticButton { }
@model PinesButton
<button type="@Model.Type"
disabled="@(Model.Disabled ? "disabled" : null)"
class="inline-flex items-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white">
@Model.ChildContent
</button>
<pines-button type="submit">Save changes</pines-button>
<pines-button type="button" disabled="true">Loading…</pines-button>

type only accepts "button", "submit" or "reset" — any other value throws at render time, so typos surface immediately instead of producing broken HTML.

Every input/checkbox/radio/select/textarea tag helper reads the validation attributes on the inner property (the one the consumer targeted with asp-for) and emits the corresponding data-val-* attributes. The set of attributes supported today is:

C# attributeEmits
[Required] (and non-nullable value types)data-val-required
[EmailAddress]data-val-email
[Url]data-val-url
[Phone]data-val-phone
[RegularExpression]data-val-regex, data-val-regex-pattern
[Range]data-val-range, data-val-range-min, data-val-range-max
[StringLength]data-val-length, data-val-length-max, data-val-length-min (when a minimum is set), maxlength

Whenever at least one validation attribute is present, data-val="true" is also emitted so jquery.validate.unobtrusive will pick the field up.