Form Field
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/form-fieldPlain-CSS theme — no build step
npx astrocraft-ui add forms/form-field --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/form-field --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/form-field --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/form-field --theme tailwind --bridge lumosLive demo
FormField
Inspect the input: aria-describedby names both the description and the message, and the invalid field carries aria-invalid — all rendered, none of it scripted.
We only use it to send the receipt.
Letters, numbers and dashes.
Handles cannot contain spaces.
Optional, and the hint is the only description.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `FormControl.astro` | `form-control` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | `[aria-invalid]` |
| `FormDescription.astro` | `form-description` | — | — | `[hidden]` |
| `FormField.astro` | `form-field` | — | — | — |
| `FormFieldset.astro` | `form-fieldset` `form-fieldset-legend` | — | — | — |
| `FormLabel.astro` | `form-label-required` | `data-size`: `sm` · `md` · `lg` | — | — |
| `FormMessage.astro` | `form-message` | — | — | `[hidden]` |
Source
What the command copies — 7 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/form-field/FormControl.astro — FormField compound part (see ../../README.md).
// The control itself, with the wiring already on it: `id`, `aria-describedby` pointing at BOTH the
// description and the message, and `aria-invalid` + `data-state="error"` from one `invalid` flag.
// Zero-JS — all of it is rendered markup.
//
// `as` chooses the element (`input` by default, `textarea` or `select` when you need them). It is a
// structural choice rather than a variant, so it is typed off an alias and renders no data attribute:
// a theme that needs to tell them apart selects the element — `textarea[data-slot="form-control"]`.
//
// It carries the same `data-size` / `data-state` surface as Input / Textarea / Select, so one theme
// rule set covers all of them (see Input.astro on why that surface replaced the deleted _field.ts).
import type { HTMLAttributes } from "astro/types";
type ControlTag = "input" | "textarea" | "select";
// The attribute sets of all three elements, so `rows` on a textarea and `multiple` on a select
// typecheck. A spread skips excess-property checking, so each branch still only gets what it uses.
type Props = HTMLAttributes<"input"> &
HTMLAttributes<"textarea"> &
HTMLAttributes<"select"> & {
id: string;
as?: ControlTag;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
invalid?: boolean;
};
const {
id,
as = "input",
size = "md",
state = "default",
invalid,
class: className,
...rest
} = Astro.props;
// The wiring the three branches share. `data-slot` stays in the markup rather than in here, because
// `scripts/slots.mjs` reads the markup to build the public attribute table — a hook it cannot see is
// a hook consumers are never told about.
const attrs = {
id,
class: className,
"data-size": size,
"data-state": invalid ? "error" : state,
"aria-describedby": `${id}-description ${id}-message`,
...rest,
};
// Written on each element rather than folded into `attrs` for the same reason as `data-slot`: it is
// the state a theme hooks (`[data-slot="form-control"][aria-invalid="true"]`), and the generated
// table only reports what it can see in the markup.
const ariaInvalid = invalid ? ("true" as const) : undefined;
---
{
as === "textarea" ? (
<textarea data-slot="form-control" aria-invalid={ariaInvalid} {...attrs}>
<slot />
</textarea>
) : as === "select" ? (
<select data-slot="form-control" aria-invalid={ariaInvalid} {...attrs}>
<slot />
</select>
) : (
<input data-slot="form-control" aria-invalid={ariaInvalid} {...attrs} />
)
}
---
// src/components/ui/forms/form-field/FormDescription.astro — FormField compound part (see ../../README.md).
// The hint under a field. `for` must be the FormControl's `id`; the id it mints from that is half of
// the `aria-describedby` FormControl already points at.
//
// It renders even when empty — hidden, so nothing shows — because FormControl's describedby names it
// unconditionally, and an IDREF that resolves to nothing is cleaner than one that dangles.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p"> & { for: string };
const { for: target, class: className, ...rest } = Astro.props;
const content = Astro.slots.has("default") ? (await Astro.slots.render("default")).trim() : "";
---
<p
id={`${target}-description`}
class={className}
data-slot="form-description"
hidden={!content}
set:html={content}
{...rest}
/>
---
// src/components/ui/forms/form-field/FormField.astro — headless primitive (see ../../README.md).
// The grouping element for one labelled control + its description + its error. Zero-JS: every
// association is a real attribute written at build time, so a screen reader gets the wiring whether
// or not the page's JavaScript ran.
//
// Astro has no context API, so a parent cannot hand a generated id to slotted children. The library
// already answers that the same way everywhere else — the root names an `id`, the parts point at it
// with `for` (Dialog/DialogTrigger, ToggleCount/ToggleCountValue, PasswordStrength). Field wiring
// follows the same convention, and the anchor is the CONTROL's own id, exactly as native HTML does:
//
// <FormField>
// <FormLabel for="email" required>Email</FormLabel>
// <FormControl id="email" type="email" required invalid />
// <FormDescription for="email">We never share it.</FormDescription>
// <FormMessage for="email">{errors.email}</FormMessage>
// </FormField>
//
// FormControl derives `aria-describedby="email-description email-message"` from that one id, so the
// idref string — the part everyone gets wrong — is never typed by hand. Render BOTH parts even when
// empty; each hides itself (the `hidden` attribute) when its slot has no content, which keeps the
// describedby references resolvable.
//
// `invalid` lives on FormControl alone, because `aria-invalid` has to sit on the control itself.
// A theme reaches the wrapper from it — `[data-slot="form-field"]:has([aria-invalid="true"])` — so
// there is one source of truth for the state rather than a flag on two elements.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div">;
const { class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="form-field" {...rest}>
<slot />
</div>
---
// src/components/ui/forms/form-field/FormFieldset.astro — FormField compound part (see ../../README.md).
// A native <fieldset> + <legend> for a set of related fields ("Billing address"). Zero-JS.
//
// The legend is a prop rather than a slot because an empty <legend> is worse than none: it names the
// group in the accessibility tree, so rendering one with nothing in it gives every control inside a
// blank group name. Omit it and you get a plain fieldset with no name, which is the honest default.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"fieldset"> & { legend?: string };
const { legend, class: className, ...rest } = Astro.props;
---
<fieldset class={className} data-slot="form-fieldset" {...rest}>
{legend && <legend data-slot="form-fieldset-legend">{legend}</legend>}
<slot />
</fieldset>
---
// src/components/ui/forms/form-field/FormLabel.astro — FormField compound part (see ../../README.md).
// The Label primitive plus the required marker, so it emits `data-slot="label"` and every theme rule
// already written for Label applies unchanged. `for` must be the FormControl's `id`.
//
// The marker is `aria-hidden`: the control carries the native `required` attribute, which assistive
// tech announces on its own. Rendering it in the accessibility tree as well would say "required"
// twice, and an asterisk is not a word.
import type { HTMLAttributes } from "astro/types";
import Label from "../../forms/label";
type Props = HTMLAttributes<"label"> & { size?: "sm" | "md" | "lg"; required?: boolean };
const { size = "md", required, class: className, ...rest } = Astro.props;
---
<Label size={size} class={className} {...rest}>
<slot />
{
required && (
<span data-slot="form-label-required" aria-hidden="true">
*
</span>
)
}
</Label>
---
// src/components/ui/forms/form-field/FormMessage.astro — FormField compound part (see ../../README.md).
// The validation message for one field, and the other half of FormControl's `aria-describedby`.
//
// `role="alert"` so a message that appears after the page has loaded (a view transition, an island
// re-render) is announced without the user having to go looking for it. Like FormDescription it
// renders empty-and-hidden rather than not at all, so the describedby reference always resolves —
// write `<FormMessage for="email">{errors.email}</FormMessage>` unconditionally.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p"> & { for: string };
const { for: target, class: className, ...rest } = Astro.props;
const content = Astro.slots.has("default") ? (await Astro.slots.render("default")).trim() : "";
---
<p
id={`${target}-message`}
class={className}
role="alert"
data-slot="form-message"
hidden={!content}
set:html={content}
{...rest}
/>
import FormControl from "./FormControl.astro";
import FormDescription from "./FormDescription.astro";
import FormField from "./FormField.astro";
import FormFieldset from "./FormFieldset.astro";
import FormLabel from "./FormLabel.astro";
import FormMessage from "./FormMessage.astro";
export { FormControl, FormDescription, FormField, FormFieldset, FormLabel, FormMessage };
export default FormField;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/form-field — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.