Skip to main content
astrocraft-ui/ components · 101

Form Field

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/form-field

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/form-field --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/form-field --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/form-field --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/form-field --theme tailwind --bridge lumos

Live 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.

Optional, and the hint is the only description.

Billing address

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.

ComponentSlotsVariantsRuntime stateNative 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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/form-field/FormControl.astro
---
import type { HTMLAttributes } from "astro/types";

type ControlTag = "input" | "textarea" | "select";

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;

const attrs = {
  id,
  class: className,
  "data-size": size,
  "data-state": invalid ? "error" : state,
  "aria-describedby": `${id}-description ${id}-message`,
  ...rest,
};

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
---
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
---
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
---
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
---
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
---
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}
/>
src/components/ui/forms/form-field/index.ts
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.