Field
The .c-field is a labeled form control. It pairs a visible label with a
native <input>, <select>, or <textarea> so you inherit constraint
validation, autofill, IME, spellcheck, and platform conventions.
State is driven by native pseudos and ARIA, then reflected onto the control and
messages - no Porchlight runtime, no class toggles, no framework adapter. Use
the wrapper <label> form for simple fields; use for/id when the field
contains grouped chrome or an action button.
Semantic HTML
<label class="c-field">
<span class="c-field__label">
Workspace name
<span class="c-field__required" aria-hidden="true">*</span>
</span>
<input
class="c-field__control"
required
placeholder="Acme Ops"
aria-describedby="workspace-name-help"
/>
<span class="c-field__hint" id="workspace-name-help"
>Use a name your team recognizes.</span
>
</label>
Use an explicit label when the field contains an input group:
<div class="c-field">
<label class="c-field__label" for="workspace-slug">Workspace URL</label>
<div class="c-input-group">
<input
id="workspace-slug"
class="c-field__control"
placeholder="acme-ops"
/>
<span class="c-input-group__addon">.porchlight.app</span>
</div>
<span class="c-field__hint"
>Use lowercase letters, numbers, and hyphens.</span
>
</div>
Class contract
| Selector | Role |
|---|---|
.c-field | Field wrapper: usually a <label>, or a container with for/id. |
.c-field--inline | Two-column label/control layout that stacks on narrow viewports. |
.c-field__label | The visible label text. |
.c-field__control | The native input/select/textarea. |
.c-field__required | Optional visual required marker inside the label. |
.c-field__hint | Helper, warning, success, or error text below the control. |
.c-field__messages | Optional stack for multiple .c-field__hint messages. |
Common Patterns
Stacked field
Use the default stacked layout for most create/edit forms, settings pages, and dialog forms.
<label class="c-field">
<span class="c-field__label">Billing email</span>
<input class="c-field__control" type="email" placeholder="ops@example.com" />
<span class="c-field__hint">Invoices and receipts are sent here.</span>
</label>
Inline field
Use .c-field--inline when labels should scan in a vertical gutter, such as
admin metadata forms or dense settings panels.
<label class="c-field c-field--inline">
<span class="c-field__label">Region</span>
<select class="c-field__control">
<option>US Central</option>
<option>EU West</option>
</select>
<span class="c-field__hint">Controls default data residency.</span>
</label>
Hidden label search
Search boxes in toolbars still need a label. Keep it visually hidden and use the
native type="search".
<label class="c-field c-field--inline">
<span class="u-sr-only">Search accounts</span>
<input
class="c-field__control"
type="search"
placeholder="Search accounts..."
/>
</label>
HTML5 validation
Use native constraints first: required, type, minlength, pattern,
min, max, and friends. Porchlight styles :user-invalid, so fields do not
show as invalid until the browser considers the user to have interacted.
<label class="c-field">
<span class="c-field__label">Workspace URL</span>
<input class="c-field__control" type="url" required placeholder="https://" />
<span class="c-field__hint">Must be a valid URL.</span>
</label>
Required marker
Use .c-field__required for a visible marker when the product needs one. The
native required attribute stays the semantic source of truth.
<label class="c-field">
<span class="c-field__label">
Workspace name
<span class="c-field__required" aria-hidden="true">*</span>
</span>
<input class="c-field__control" required />
</label>
Message tones
Use .c-field__hint for one message, or .c-field__messages when helper,
warning, success, and error messages need to stack. Add
data-tone="warning", data-tone="danger", or data-tone="success" for an
explicit visual tone.
<div class="c-field">
<label class="c-field__label" for="workspace-slug">Workspace slug</label>
<input
id="workspace-slug"
class="c-field__control"
required
aria-invalid="true"
aria-describedby="workspace-slug-help workspace-slug-error"
/>
<span class="c-field__messages">
<span class="c-field__hint" id="workspace-slug-help"
>Use lowercase letters, numbers, and hyphens.</span
>
<span
class="c-field__hint"
id="workspace-slug-error"
data-tone="danger"
role="alert"
>Spaces are not allowed.</span
>
</span>
</div>
Server or framework validation
For errors returned by a server, hypermedia swap, reactive state, or component
framework, set aria-invalid="true" on the native control and connect the
message with aria-describedby. Porchlight treats that state the same as
:user-invalid. Set aria-invalid="false" or remove the attribute for a valid
server state.
<div class="c-field">
<label class="c-field__label" for="invite-email">Invite email</label>
<input
id="invite-email"
class="c-field__control"
type="email"
value="already-used@example.com"
aria-invalid="true"
aria-describedby="invite-email-error"
/>
<span class="c-field__hint" id="invite-email-error" role="alert">
That email is already invited.
</span>
</div>
Framework-agnostic contract
Porchlight does not care which tool rendered the field. Plain HTML, server-rendered HATEOAS fragments, htmx-style swaps, Alpine-style attribute bindings, Vue, React, and heavier app frameworks should all emit the same native HTML contract:
<div class="c-field">
<label class="c-field__label" for="email">Email</label>
<input
id="email"
class="c-field__control"
name="email"
type="email"
required
aria-invalid="true"
aria-describedby="email-error"
/>
<span class="c-field__hint" id="email-error" data-tone="danger" role="alert">
Enter a valid email address.
</span>
</div>
Framework-specific attributes may exist in application markup, but Porchlight selectors do not depend on them. Dynamic applications own validation timing and DOM updates; Porchlight only styles the resulting semantic HTML.
Tokens consumed
--pl-color-{border,surface,text,text-muted,danger,danger-text,success-text,warning-text},
--pl-focus-color, --pl-focus-size,
--pl-control-{block-size,padding-inline,radius,border-width},
--pl-duration-1, --pl-ease-standard.
Tokens exposed (component-local)
| Token | Default | Purpose |
|---|---|---|
--c-field-border | --pl-color-border | Control border; overridden on focus/invalid. |
--c-field-bg | --pl-color-surface | Control fill. |
--c-field-fg | --pl-color-text | Control text. |
--c-field-inline-label-size | 10rem | Label column size for .c-field--inline. |
States
| State | Selector | Behavior |
|---|---|---|
| focus | :focus-visible (control) | crisp accent ring + soft glow (box-shadow) |
| invalid | :user-invalid, [aria-invalid="true"] | danger ring + glow, untoned hint -> danger-text |
| focused invalid | invalid + :focus-visible | danger border/hint + standard focus glow |
| disabled | :disabled (control) | field muted (0.55 opacity), not-allowed |
| placeholder | ::placeholder | text-muted |
| message tone | .c-field__hint[data-tone] | success, warning, or danger message color |
Accessibility
- Label association: the wrapper is a
<label>, so clicking the label focuses the control. When the field contains an action button or multiple focusable elements, use<label for>and a matching controlidinstead. - Required marker:
.c-field__requiredis visual only and should bearia-hidden; the nativerequiredattribute is what browsers and assistive technology use. - Focus: the control draws a crisp 2px accent ring with a soft blurred
glow (a layered
box-shadow). The border is intentionally NOT recolored - recoloring it alongside a ring produced a “double blue stroke” (solid border- translucent ring). The ring is the single focus indicator; the border stays as the neutral field edge. This reads as a modern SaaS input rather than a browser default outline.
- Invalidity: uses
:user-invalid(Baseline 2024) for HTML5 constraints and[aria-invalid="true"]for server/framework errors. Never rely on color alone: the hint text also changes todanger-text. When an invalid control has keyboard focus, the ring uses the standard focus color so the active typing target does not flash a destructive halo. - Error text association: for screen-reader users, add
aria-describedbyon the control pointing at the hint’sid(the wrapper-label association covers the label; the hint needs the explicit link). - Framework agnostic: hypermedia, htmx, Alpine, Vue, React,
server-rendered HTML, and plain forms all use the same DOM contract: native
controls, constraints,
aria-invalid, andaria-describedby. - Read-only vs disabled: use
readonlywhen the value can be selected or copied, anddisabledwhen the control is unavailable and should be skipped by form submission.
Theme, density, RTL, motion
- Light/dark: all colors via tokens (
light-dark()). - Density: sizing from
--pl-control-block-size;[data-density]on an ancestor. - RTL: logical properties throughout (
padding-inline,inline-size,min-block-size). - Reduced motion: the border/box-shadow transitions are zeroed by the themes layer.
- Forced colors: border falls back to
ButtonBorder.