# Field

> Labels, descriptions and errors wired to their control, with validation states and layouts for forms.

Docs: https://hextaui.com/docs/field
Markdown: https://hextaui.com/docs/field.md

```tsx title="components/examples/field/demo.tsx"
"use client"

import { Form } from "@base-ui/react/form"
import { IconAt } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldCounter,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldStatus,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export function FieldDemo() {
  return (
    <Form
      className="w-full max-w-sm"
      onSubmit={(event) => event.preventDefault()}
    >
      <FieldGroup indicator="optional">
        <Field validationMode="onBlur">
          <FieldLabel>Display name</FieldLabel>
          <Input name="name" autoComplete="name" required />
          <FieldError match="valueMissing">
            Add the name people will see.
          </FieldError>
        </Field>
        <Field validationMode="onChange">
          <FieldLabel>Handle</FieldLabel>
          <InputGroup>
            <InputGroupInput
              name="handle"
              autoComplete="username"
              pattern="[a-z0-9_]{3,15}"
              maxLength={15}
              required
            />
            <InputGroupAddon>
              <IconAt />
            </InputGroupAddon>
            <InputGroupAddon align="inline-end">
              <FieldStatus />
            </InputGroupAddon>
          </InputGroup>
          <FieldError match="valueMissing">Pick a handle.</FieldError>
          <FieldError match="patternMismatch">
            Use 3–15 lowercase letters, numbers or underscores.
          </FieldError>
        </Field>
        <Field>
          <FieldLabel>Bio</FieldLabel>
          <InputGroup>
            <InputGroupTextarea name="bio" maxLength={160} />
          </InputGroup>
          <div className="flex items-baseline justify-between gap-3">
            <FieldDescription>Shown on your profile.</FieldDescription>
            <FieldCounter />
          </div>
        </Field>
        <Button type="submit">Save profile</Button>
      </FieldGroup>
    </Form>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/field.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/field.tsx"
"use client"

import * as React from "react"
import { Field as FieldPrimitive } from "@base-ui/react/field"
import { Fieldset as FieldsetPrimitive } from "@base-ui/react/fieldset"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { IconAlertCircle, IconCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useSizeMorph } from "@/lib/motion"

import { useComposedRef } from "@/hooks/use-composed-ref"
import { useMergedRef } from "@/hooks/use-merged-ref"
import {
  InputCount,
  type InputControl,
  type InputCountProps,
} from "@/components/ui/input"
import { Separator, type SeparatorProps } from "@/components/ui/separator"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const FieldContext = React.createContext(false)

type FieldIndicator = "required" | "optional"

const FieldIndicatorContext = React.createContext<FieldIndicator | null>(null)

function IndicatorScope({
  indicator,
  children,
}: {
  indicator: FieldIndicator | null | undefined
  children: React.ReactNode
}) {
  if (indicator === undefined) {
    return children
  }
  return (
    <FieldIndicatorContext.Provider value={indicator}>
      {children}
    </FieldIndicatorContext.Provider>
  )
}

type FieldSetProps = FieldsetPrimitive.Root.Props & {
  indicator?: FieldIndicator | null
}

function FieldSet({ className, indicator, ...props }: FieldSetProps) {
  return (
    <IndicatorScope indicator={indicator}>
      <FieldSetRoot className={className} {...props} />
    </IndicatorScope>
  )
}

function FieldSetRoot({ className, ...props }: FieldsetPrimitive.Root.Props) {
  return (
    <FieldsetPrimitive.Root
      data-slot="field-set"
      className={mergeClassName(
        "flex min-w-0 flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 data-[slot=checkbox-group]:gap-3 data-[slot=radio-group]:gap-3",
        className
      )}
      {...props}
    />
  )
}

type FieldLegendProps = FieldsetPrimitive.Legend.Props & {
  variant?: "legend" | "label"
}

function FieldLegend({
  className,
  variant = "legend",
  ...props
}: FieldLegendProps) {
  return (
    <FieldsetPrimitive.Legend
      data-slot="field-legend"
      data-variant={variant}
      className={mergeClassName(
        "mb-3 font-medium text-pretty wrap-anywhere text-foreground data-[variant=label]:text-sm data-[variant=legend]:text-base data-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

type FieldGroupProps = useRender.ComponentProps<"div"> & {
  indicator?: FieldIndicator | null
}

function FieldGroup({ indicator, ...props }: FieldGroupProps) {
  return (
    <IndicatorScope indicator={indicator}>
      <FieldGroupRoot {...props} />
    </IndicatorScope>
  )
}

function FieldGroupRoot({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "group/field-group @container/field-group flex w-full min-w-0 flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
          className
        ),
      },
      props,
      { "data-slot": "field-group" } as React.ComponentProps<"div">
    ),
  })
}

const fieldVariants = cva(
  "group/field flex w-full min-w-0 gap-3 [--field-gap:calc(var(--spacing)*3)]",
  {
    variants: {
      orientation: {
        vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
        horizontal:
          "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[data-slot=checkbox],[role=checkbox],[role=radio]]:mt-px",
        responsive:
          "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[data-slot=checkbox],[role=checkbox],[role=radio]]:mt-px",
      },
    },
    defaultVariants: {
      orientation: "vertical",
    },
  }
)

type FieldOrientation = NonNullable<
  VariantProps<typeof fieldVariants>["orientation"]
>

type FieldProps = FieldPrimitive.Root.Props & {
  orientation?: FieldOrientation | null
  indicator?: FieldIndicator | null
}

function Field({
  className,
  orientation = "vertical",
  indicator,
  ...props
}: FieldProps) {
  const resolvedOrientation = orientation ?? "vertical"

  return (
    <IndicatorScope indicator={indicator}>
      <FieldContext.Provider value>
        <FieldPrimitive.Root
          data-slot="field"
          data-orientation={resolvedOrientation}
          className={mergeClassName(
            fieldVariants({ orientation: resolvedOrientation }),
            className
          )}
          {...props}
        />
      </FieldContext.Provider>
    </IndicatorScope>
  )
}

function FieldItem({ className, ...props }: FieldPrimitive.Item.Props) {
  return (
    <FieldPrimitive.Item
      data-slot="field-item"
      className={mergeClassName(
        "flex min-w-0 items-start gap-3 has-[>[data-slot=field-content]]:[&>[data-slot=checkbox],[role=checkbox],[role=radio]]:mt-px",
        className
      )}
      {...props}
    />
  )
}

function FieldContent({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "group/field-content flex min-w-0 flex-1 flex-col gap-1 leading-snug [--field-gap:calc(var(--spacing)*1)]",
          className
        ),
      },
      props,
      { "data-slot": "field-content" } as React.ComponentProps<"div">
    ),
  })
}

const fieldLabelClassName =
  "group/field-label peer/field-label flex w-fit min-w-0 items-center gap-2 text-sm leading-snug font-medium text-pretty wrap-anywhere text-foreground select-none group-data-disabled/field:cursor-not-allowed group-data-disabled/field:opacity-50 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:items-stretch has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:inset-ring-(length:--hairline) has-[>[data-slot=field]]:inset-ring-border has-[>[data-slot=field]]:transition-[background-color,box-shadow] has-[>[data-slot=field]]:duration-150 has-[>[data-slot=field]]:ease-out-cubic has-[>[data-slot=field]]:has-focus-visible:ring-3 has-[>[data-slot=field]]:has-focus-visible:ring-focus-ring has-[>[data-slot=field]]:has-focus-visible:inset-ring-ring has-[>[data-slot=field]]:has-data-checked:bg-muted/60 has-[>[data-slot=field]]:has-data-checked:inset-ring-foreground/25 has-[>[data-slot=field]]:has-[[data-disabled]]:cursor-not-allowed has-[>[data-slot=field]]:has-[[data-disabled]]:opacity-50 *:data-[slot=field]:p-3 has-[>[data-slot=field]]:motion-reduce:transition-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground [@media(hover:hover)]:has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50"

function StandaloneFieldLabel({
  className,
  render,
  nativeLabel: _nativeLabel,
  ...props
}: FieldPrimitive.Label.Props) {
  const resolved = mergeClassName(fieldLabelClassName, className)

  return useRender({
    defaultTagName: "label",
    render: render as useRender.ComponentProps<"label">["render"],
    props: mergeProps<"label">(
      {
        className:
          typeof resolved === "function"
            ? resolved({} as FieldPrimitive.Label.State)
            : resolved,
      },
      props as React.ComponentProps<"label">,
      { "data-slot": "field-label" } as React.ComponentProps<"label">
    ),
  })
}

function FieldIndicatorMark({
  indicator,
  optionalText,
}: {
  indicator: FieldIndicator
  optionalText: React.ReactNode
}) {
  return (
    <span
      aria-hidden="true"
      data-slot="field-indicator"
      data-indicator={indicator}
      className={cn(
        "shrink-0 self-start font-normal whitespace-nowrap text-muted-foreground group-has-[>[data-slot=field]]/field-label:hidden",
        indicator === "required"
          ? "-ms-1.5 hidden group-has-[:required,[aria-required=true]]/field:inline"
          : "group-has-[:required,[aria-required=true]]/field:hidden"
      )}
    >
      {indicator === "required" ? "*" : optionalText}
    </span>
  )
}

type FieldLabelProps = FieldPrimitive.Label.Props & {
  optionalText?: React.ReactNode
}

function FieldLabel({
  className,
  children,
  optionalText = "Optional",
  ...props
}: FieldLabelProps) {
  const inField = React.useContext(FieldContext)
  const indicator = React.useContext(FieldIndicatorContext)

  if (!inField) {
    return (
      <StandaloneFieldLabel className={className} {...props}>
        {children}
      </StandaloneFieldLabel>
    )
  }

  return (
    <FieldPrimitive.Label
      data-slot="field-label"
      className={mergeClassName(fieldLabelClassName, className)}
      {...props}
    >
      {children}
      {indicator && (
        <FieldIndicatorMark indicator={indicator} optionalText={optionalText} />
      )}
    </FieldPrimitive.Label>
  )
}

function FieldStatus({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden="true"
      data-slot="field-status"
      className={cn(
        "inline-grid size-4 shrink-0 place-items-center *:col-start-1 *:row-start-1 *:size-4",
        className
      )}
      {...props}
    >
      <IconCheck className="hidden text-success group-data-dirty/field:group-data-valid/field:block motion-safe:animate-checkbox-draw motion-safe:[stroke-dasharray:22] motion-safe:[stroke-dashoffset:22]" />
      <IconAlertCircle className="hidden text-destructive group-data-invalid/field:block motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-50" />
    </span>
  )
}

const counterControl =
  "textarea, input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=file])"

type FieldCounterProps = Omit<
  InputCountProps,
  "length" | "maxLength" | "controlRef"
>

function FieldCounter({ ref, ...props }: FieldCounterProps) {
  const [counterRef, setRef] = useComposedRef<HTMLSpanElement>(ref)
  const controlRef = React.useRef<InputControl | null>(null)
  const [count, setCount] = React.useState({
    length: 0,
    maxLength: null as number | null,
  })

  React.useEffect(() => {
    const field = counterRef.current?.closest("[data-slot=field]")
    const control = field?.querySelector<InputControl>(counterControl)
    if (!control) {
      return
    }
    controlRef.current = control
    let timer: ReturnType<typeof setTimeout> | undefined

    const sync = () => {
      const length = control.value.length
      const maxLength = control.maxLength > 0 ? control.maxLength : null
      setCount((previous) =>
        previous.length === length && previous.maxLength === maxLength
          ? previous
          : { length, maxLength }
      )
    }
    const onReset = () => {
      clearTimeout(timer)
      timer = setTimeout(sync)
    }

    const onInput = (event: Event) => {
      if (event.target === control) {
        sync()
      }
    }

    sync()
    window.addEventListener("input", onInput)
    control.form?.addEventListener("reset", onReset)
    return () => {
      window.removeEventListener("input", onInput)
      control.form?.removeEventListener("reset", onReset)
      clearTimeout(timer)
      controlRef.current = null
    }
  }, [counterRef])

  return (
    <InputCount
      ref={setRef}
      data-slot="field-counter"
      length={count.length}
      maxLength={count.maxLength}
      controlRef={controlRef}
      {...props}
    />
  )
}

function FieldTitle({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "flex w-fit min-w-0 items-center gap-2 text-sm leading-snug font-medium text-pretty wrap-anywhere text-foreground group-data-disabled/field:opacity-50",
          className
        ),
      },
      props,
      { "data-slot": "field-title" } as React.ComponentProps<"div">
    ),
  })
}

const fieldDescriptionClassName =
  "min-w-0 text-start text-sm leading-normal font-normal text-pretty wrap-anywhere text-muted-foreground group-data-[orientation=horizontal]/field:text-balance group-data-disabled/field:opacity-50 [&_a:not([data-slot])]:text-foreground [&_a:not([data-slot])]:underline [&_a:not([data-slot])]:decoration-foreground/40 [&_a:not([data-slot])]:underline-offset-4 [&_a:not([data-slot])]:hover:decoration-foreground [[data-variant=legend]+&]:-mt-1.5"

function FieldDescription({
  className,
  render,
  ...props
}: FieldPrimitive.Description.Props) {
  const inField = React.useContext(FieldContext)
  const resolved = mergeClassName(fieldDescriptionClassName, className)
  const standalone = useRender({
    defaultTagName: "p",
    render: render as useRender.ComponentProps<"p">["render"],
    enabled: !inField,
    props: mergeProps<"p">(
      {
        className:
          typeof resolved === "function"
            ? resolved({} as FieldPrimitive.Description.State)
            : resolved,
      },
      props as React.ComponentProps<"p">,
      { "data-slot": "field-description" } as React.ComponentProps<"p">
    ),
  })

  if (!inField) {
    return standalone
  }

  return (
    <FieldPrimitive.Description
      data-slot="field-description"
      className={resolved}
      render={render}
      {...props}
    />
  )
}

function hasContent(children: React.ReactNode) {
  return React.Children.toArray(children).some(
    (child) => typeof child !== "string" || child.trim() !== ""
  )
}

function FieldSeparator({ className, children, ...props }: SeparatorProps) {
  return (
    <Separator
      data-slot="field-separator"
      className={mergeClassName(
        hasContent(children) ? "-my-2 min-h-5 text-sm" : "my-0.5",
        className
      )}
      {...props}
    >
      {children}
    </Separator>
  )
}

type FieldErrorMessage = { message?: string } | undefined | null

type FieldErrorProps = Omit<FieldPrimitive.Error.Props, "children"> & {
  children?: React.ReactNode
  errors?: FieldErrorMessage[]
}

function uniqueMessages(errors: FieldErrorMessage[] | undefined) {
  if (!errors) {
    return []
  }
  const messages = errors
    .map((error) => error?.message?.trim())
    .filter((message): message is string => Boolean(message))
  return [...new Set(messages)]
}

function renderMessages(messages: string[]) {
  if (messages.length === 0) {
    return null
  }
  if (messages.length === 1) {
    return messages[0]
  }
  return (
    <ul>
      {messages.map((message) => (
        <li key={message}>{message}</li>
      ))}
    </ul>
  )
}

const fieldErrorClassName =
  "grid min-w-0 grid-rows-[1fr] text-sm leading-normal font-normal text-pretty wrap-anywhere text-destructive transition-[grid-template-rows,opacity,margin-top] duration-200 ease-out-quint data-ending-style:-mt-(--field-gap) data-ending-style:grid-rows-[0fr] data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:-mt-(--field-gap) data-starting-style:grid-rows-[0fr] data-starting-style:opacity-0 motion-reduce:transition-none [&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:ps-4"

function FieldError({
  className,
  children,
  errors,
  match,
  render,
  ref,
  ...props
}: FieldErrorProps) {
  const external = errors !== undefined
  const messages = uniqueMessages(errors)
  const messageKey = messages.join("\u0000")
  const [shown, setShown] = React.useState<string[]>(messages)
  const [shownKey, setShownKey] = React.useState(messageKey)

  if (messages.length > 0 && messageKey !== shownKey) {
    setShownKey(messageKey)
    setShown(messages)
  }

  const content = children ?? (external ? renderMessages(shown) : undefined)
  const morphRef = useSizeMorph<HTMLDivElement>({
    axis: "height",
    duration: 200,
  })
  const errorRef = useMergedRef(ref, morphRef)
  const resolvedMatch = external
    ? messages.length > 0 || (children !== undefined && match === true)
    : match

  return (
    <FieldPrimitive.Error
      ref={errorRef}
      data-slot="field-error"
      match={resolvedMatch}
      className={mergeClassName(fieldErrorClassName, className)}
      render={(errorProps, state) => {
        const inner = (
          <div
            data-slot="field-error-content"
            className="min-h-0 overflow-hidden"
          >
            <div
              key={external ? shownKey : undefined}
              className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1"
            >
              {errorProps.children}
            </div>
          </div>
        )
        const props = errorProps
        if (typeof render === "function") {
          return render({ ...props, children: inner }, state)
        }
        if (React.isValidElement(render)) {
          return React.cloneElement(render, props, inner)
        }
        return <div {...props}>{inner}</div>
      }}
      {...props}
      {...(content === undefined ? {} : { children: content })}
    />
  )
}

const FieldValidity = FieldPrimitive.Validity

export {
  Field,
  FieldContent,
  FieldCounter,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldItem,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldStatus,
  FieldTitle,
  FieldValidity,
  fieldVariants,
}
export type {
  FieldCounterProps,
  FieldErrorProps,
  FieldGroupProps,
  FieldIndicator,
  FieldLabelProps,
  FieldLegendProps,
  FieldOrientation,
  FieldProps,
  FieldSetProps,
}
```

```tsx title="components/ui/input.tsx"
"use client"

import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useAutosize } from "@/hooks/use-autosize"
import { useComposedRef } from "@/hooks/use-composed-ref"
import { useInvalidShake } from "@/hooks/use-invalid-shake"
import { useMergedRef } from "@/hooks/use-merged-ref"
import { prefersReducedMotion } from "@/lib/motion"

import { NumberFlow } from "@/components/ui/number-flow"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const inputVariants = cva(
  "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70",
  {
    variants: {
      size: {
        sm: "h-8 px-2.5 file:pe-2.5",
        default: "h-9 px-3",
        lg: "h-10 px-3.5",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

type InputControl = HTMLInputElement | HTMLTextAreaElement

type InputProps = Omit<InputPrimitive.Props, "size"> &
  VariantProps<typeof inputVariants> & {
    htmlSize?: number
    shake?: boolean
  }

function Input({
  className,
  size = "default",
  htmlSize,
  shake = true,
  ref,
  ...props
}: InputProps) {
  const resolvedSize = size ?? "default"
  const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref)
  useInvalidShake(inputRef, shake)

  return (
    <InputPrimitive
      ref={setRef}
      data-slot="input"
      data-size={resolvedSize}
      size={htmlSize}
      className={mergeClassName(
        inputVariants({ size: resolvedSize }),
        className
      )}
      {...props}
    />
  )
}

function defaultAnnouncement(remaining: number) {
  return remaining === 0
    ? "Character limit reached"
    : `${remaining} ${remaining === 1 ? "character" : "characters"} left`
}

type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & {
  length: number
  maxLength?: number | null
  controlRef?: React.RefObject<InputControl | null>
  threshold?: number
  announcement?: (remaining: number) => string
}

function InputCount({
  className,
  length,
  maxLength = null,
  controlRef,
  threshold,
  announcement = defaultAnnouncement,
  ref,
  ...props
}: InputCountProps) {
  const max = maxLength !== null && maxLength > 0 ? maxLength : null
  const remaining = max === null ? null : Math.max(0, max - length)
  const near =
    max === null
      ? Infinity
      : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20)))
  const state =
    remaining === null
      ? undefined
      : remaining === 0
        ? "limit"
        : remaining <= near
          ? "near"
          : undefined

  const [message, setMessage] = React.useState("")
  const [lastState, setLastState] = React.useState(state)
  if (state !== lastState) {
    setLastState(state)
    setMessage(state && remaining !== null ? announcement(remaining) : "")
  }

  const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref)

  React.useEffect(() => {
    const element = controlRef?.current
    const count = countRef.current
    if (!element || !count || max === null) {
      return
    }
    let timer: ReturnType<typeof setTimeout> | undefined
    const onBeforeInput = (event: Event) => {
      const input = event as InputEvent
      if (
        !input.inputType?.startsWith("insert") ||
        element.value.length < max ||
        element.selectionStart !== element.selectionEnd ||
        prefersReducedMotion()
      ) {
        return
      }
      count.removeAttribute("data-bump")
      void count.offsetWidth
      count.setAttribute("data-bump", "")
      clearTimeout(timer)
      timer = setTimeout(() => count.removeAttribute("data-bump"), 400)
    }
    element.addEventListener("beforeinput", onBeforeInput)
    return () => {
      element.removeEventListener("beforeinput", onBeforeInput)
      clearTimeout(timer)
    }
  }, [controlRef, max])

  return (
    <span
      ref={setCountRef}
      data-slot="input-count"
      data-state={state}
      className={cn(
        "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground",
        className
      )}
      {...props}
    >
      <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline">
        <NumberFlow value={length} />
        {max !== null && <span>/{max}</span>}
      </span>
      <span role="status" className="sr-only">
        {message}
      </span>
    </span>
  )
}

export {
  Input,
  InputCount,
  useAutosize,
  inputVariants,
  useComposedRef,
  useInvalidShake,
  useMergedRef,
}
export type { InputControl, InputCountProps, InputProps }
```

```tsx title="components/ui/number-flow.tsx"
"use client"

import * as React from "react"
import { cn } from "cn"

import { easeSpring } from "@/lib/motion"

type NumberFlowTrend = "auto" | "up" | "down" | "shortest"

type NumberFlowChar = {
  key: string
  section: number
  order: number
  value: string
  digit: number | null
}

type NumberFlowItem = NumberFlowChar & {
  exiting: boolean
  exitId: number
}

type NumberFlowProps = Omit<
  React.ComponentProps<"span">,
  "children" | "prefix"
> & {
  value: number
  locales?: Intl.LocalesArgument
  format?: Intl.NumberFormatOptions
  prefix?: string
  suffix?: string
  trend?: NumberFlowTrend
  duration?: number
  easing?: string
  animated?: boolean
  onAnimationsStart?: () => void
  onAnimationsFinish?: () => void
}

const rows = 30

const restingClasses =
  "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]"

function getSpinDelta(
  from: number,
  to: number,
  trend: Exclude<NumberFlowTrend, "auto">
) {
  let delta = to - from

  if (trend === "up") {
    if (delta < 0) {
      delta += 10
    }
  } else if (trend === "down") {
    if (delta > 0) {
      delta -= 10
    }
  } else if (delta > 5) {
    delta -= 10
  } else if (delta < -4) {
    delta += 10
  }

  return delta
}

function digitGlyphs(
  formatter: Intl.NumberFormat,
  locales: Intl.LocalesArgument
) {
  const { numberingSystem } = formatter.resolvedOptions()
  const plain = new Intl.NumberFormat(locales, {
    numberingSystem,
    useGrouping: false,
  })

  return Array.from({ length: 10 }, (_, digit) => plain.format(digit))
}

function formatChars(
  value: number,
  locales: Intl.LocalesArgument,
  format: Intl.NumberFormatOptions | undefined,
  prefix: string | undefined,
  suffix: string | undefined
) {
  const formatter = new Intl.NumberFormat(locales, format)
  const glyphs = digitGlyphs(formatter, locales)
  const parts = formatter.formatToParts(value)
  const integerCount = parts
    .filter((part) => part.type === "integer")
    .reduce((count, part) => count + Array.from(part.value).length, 0)
  const chars: NumberFlowChar[] = []
  const occurrences = new Map<string, number>()
  let integerIndex = 0
  let fractionIndex = 0
  let seenNumber = false

  function symbol(section: number, type: string, text: string) {
    const name = `${section}-${type}`
    const occurrence = occurrences.get(name) ?? 0
    occurrences.set(name, occurrence + 1)
    chars.push({
      key: `s${name}-${occurrence}`,
      section,
      order: occurrence,
      value: text,
      digit: null,
    })
  }

  if (prefix) {
    chars.push({
      key: "prefix",
      section: 0,
      order: 0,
      value: prefix,
      digit: null,
    })
  }

  for (const part of parts) {
    if (part.type === "integer") {
      seenNumber = true
      for (const glyph of Array.from(part.value)) {
        const place = integerCount - 1 - integerIndex
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `i${place}`,
          section: 2,
          order: -place,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        integerIndex += 1
      }
    } else if (part.type === "group") {
      const place = integerCount - 1 - integerIndex
      chars.push({
        key: `g${place}`,
        section: 2,
        order: -place - 0.5,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "decimal") {
      seenNumber = true
      chars.push({
        key: "decimal",
        section: 3,
        order: 0,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "fraction") {
      for (const glyph of Array.from(part.value)) {
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `f${fractionIndex}`,
          section: 4,
          order: fractionIndex,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        fractionIndex += 1
      }
    } else {
      symbol(seenNumber ? 5 : 1, part.type, part.value)
    }
  }

  if (suffix) {
    chars.push({
      key: "suffix",
      section: 6,
      order: 0,
      value: suffix,
      digit: null,
    })
  }

  return chars
}

function signature(chars: NumberFlowChar[]) {
  return chars.map((char) => `${char.key}:${char.value}`).join("|")
}

function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) {
  const keys = new Set(next.map((char) => char.key))
  const kept = previous
    .filter((item) => !keys.has(item.key))
    .map((item) =>
      item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 }
    )
  const fresh = next.map((char) => {
    const before = previous.find((item) => item.key === char.key)
    return { ...char, exiting: false, exitId: before?.exitId ?? 0 }
  })

  return [...kept, ...fresh].sort(
    (a, b) => a.section - b.section || a.order - b.order
  )
}

function subscribeReducedMotion(callback: () => void) {
  if (typeof window.matchMedia !== "function") {
    return () => {}
  }
  const query = window.matchMedia("(prefers-reduced-motion: reduce)")
  query.addEventListener("change", callback)
  return () => query.removeEventListener("change", callback)
}

function getReducedMotion() {
  return (
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

function canAnimate() {
  return (
    typeof Element !== "undefined" &&
    typeof Element.prototype.animate === "function"
  )
}

function liveIndex(column: HTMLElement) {
  const height = column.offsetHeight

  if (!height) {
    return null
  }

  const translate = getComputedStyle(column).translate
  const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0")
  const amount = parseFloat(y) || 0

  return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows
}

function NumberFlow({
  value,
  locales = "en-US",
  format,
  prefix,
  suffix,
  trend = "auto",
  duration = 600,
  easing = easeSpring,
  animated = true,
  onAnimationsStart,
  onAnimationsFinish,
  className,
  ...props
}: NumberFlowProps) {
  const chars = formatChars(value, locales, format, prefix, suffix)
  const key = signature(chars)
  const reducedMotion = React.useSyncExternalStore(
    subscribeReducedMotion,
    getReducedMotion,
    () => false
  )
  const animate = animated && !reducedMotion && canAnimate()
  const [state, setState] = React.useState(() => ({
    key,
    items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
  }))

  if (state.key !== key) {
    setState({
      key,
      items: animate
        ? mergeItems(state.items, chars)
        : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
    })
  }

  const rootRef = React.useRef<HTMLSpanElement>(null)
  const previousRef = React.useRef<Map<
    string,
    { digit: number | null; exiting: boolean; exitId: number }
  > | null>(null)
  const valueRef = React.useRef(value)
  const batchRef = React.useRef(0)
  const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish })
  const finishExitRef = React.useRef((itemKey: string, exitId: number) => {
    setState((current) => ({
      ...current,
      items: current.items.filter(
        (item) =>
          !(item.key === itemKey && item.exiting && item.exitId === exitId)
      ),
    }))
  })

  React.useLayoutEffect(() => {
    callbacksRef.current = { onAnimationsStart, onAnimationsFinish }
  })

  React.useLayoutEffect(() => {
    const root = rootRef.current
    const previous = previousRef.current
    const current = new Map(
      state.items.map((item) => [
        item.key,
        { digit: item.digit, exiting: item.exiting, exitId: item.exitId },
      ])
    )
    const previousValue = valueRef.current

    previousRef.current = current
    valueRef.current = value

    if (!root || !previous || !animate) {
      return
    }

    const direction =
      trend !== "auto"
        ? trend
        : value > previousValue
          ? "up"
          : value < previousValue
            ? "down"
            : "shortest"
    const timing = { duration, easing }
    const started: Animation[] = []
    const cells = root.querySelectorAll<HTMLElement>(
      ":scope > [data-number-flow-key]"
    )

    for (const cell of cells) {
      const itemKey = cell.dataset.numberFlowKey!
      const item = current.get(itemKey)
      const before = previous.get(itemKey)

      if (!item || typeof cell.animate !== "function") {
        continue
      }

      const column = cell.querySelector<HTMLElement>(
        "[data-slot=number-flow-column]"
      )
      const entering = !item.exiting && (!before || before.exiting)
      const leaving = item.exiting && (!before || !before.exiting)

      if (entering || leaving) {
        const running = cell.getAnimations()
        const fromWidth = running.length
          ? cell.getBoundingClientRect().width
          : entering
            ? 0
            : cell.getBoundingClientRect().width
        const fromOpacity = running.length
          ? Number(getComputedStyle(cell).opacity)
          : entering
            ? 0
            : 1
        running.forEach((animation) => animation.cancel())
        const toWidth = entering ? cell.getBoundingClientRect().width : 0
        const animation = cell.animate(
          [
            { width: `${fromWidth}px`, opacity: fromOpacity },
            { width: `${toWidth}px`, opacity: entering ? 1 : 0 },
          ],
          { ...timing, fill: leaving ? "forwards" : "none" }
        )
        started.push(animation)
        if (leaving) {
          const exitId = item.exitId
          animation.finished.then(
            () => finishExitRef.current(itemKey, exitId),
            () => {}
          )
        }
      }

      if (!column || item.digit === null || item.exiting) {
        continue
      }

      const target = item.digit
      const running = column.getAnimations()
      const live = running.length ? liveIndex(column) : null
      running.forEach((animation) => animation.cancel())
      const from =
        live !== null
          ? live - 10
          : entering
            ? 0
            : before && before.digit !== null
              ? before.digit
              : target

      if (live === null && from === target && !entering) {
        continue
      }

      const delta = getSpinDelta(
        from,
        target,
        entering && live === null ? "up" : direction
      )

      if (delta === 0) {
        continue
      }

      started.push(
        column.animate(
          [
            { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` },
            { translate: `0 ${(-(target + 10) / rows) * 100}%` },
          ],
          timing
        )
      )
    }

    if (!started.length) {
      return
    }

    batchRef.current += 1
    const batch = batchRef.current
    callbacksRef.current.onAnimationsStart?.()
    Promise.all(started.map((animation) => animation.finished)).then(
      () => {
        if (batch === batchRef.current) {
          callbacksRef.current.onAnimationsFinish?.()
        }
      },
      () => {}
    )
  }, [state.items, animate, duration, easing, trend, value])

  return (
    <span
      ref={rootRef}
      dir="ltr"
      className={cn("inline-block max-w-full tabular-nums", className)}
      {...props}
      data-slot="number-flow"
    >
      <span data-slot="number-flow-text" className="sr-only">
        {state.items
          .filter((item) => !item.exiting)
          .map((item) => item.value)
          .join("")}
      </span>
      {state.items.map((item) =>
        item.digit === null ? (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-symbol"
            aria-hidden
            className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap"
          >
            {item.value}
          </span>
        ) : (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-digit"
            aria-hidden
            className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]"
          >
            <span className="opacity-0">{item.value}</span>
            <span
              aria-hidden
              data-slot="number-flow-column"
              data-digit={item.digit}
              className={cn(
                "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none",
                restingClasses
              )}
            >
              {Array.from({ length: rows }, (_, row) => (
                <span key={row} className="block py-[0.2em]">
                  {glyphFor(item, row)}
                </span>
              ))}
            </span>
          </span>
        )
      )}
    </span>
  )
}

function glyphFor(item: NumberFlowItem, row: number) {
  return String.fromCodePoint(
    item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10)
  )
}

export {
  NumberFlow,
  getSpinDelta,
  formatChars,
  type NumberFlowProps,
  type NumberFlowTrend,
}
```

```tsx title="components/ui/separator.tsx"
"use client"

import * as React from "react"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"

type SeparatorAlign = "start" | "center" | "end"

type SeparatorOrientation = NonNullable<SeparatorPrimitive.Props["orientation"]>

type SeparatorProps = SeparatorPrimitive.Props & {
  decorative?: boolean
  align?: SeparatorAlign
}

const lineClassName: Record<SeparatorOrientation, string> = {
  horizontal:
    "h-(--hairline) w-full shrink-0 bg-border forced-colors:bg-canvas-text",
  vertical:
    "w-(--hairline) shrink-0 self-stretch bg-border forced-colors:bg-canvas-text",
}

const labelledClassName: Record<SeparatorOrientation, string> = {
  horizontal:
    "flex w-full shrink-0 items-center gap-3 text-xs text-muted-foreground before:h-(--hairline) before:min-w-4 before:flex-1 before:bg-border after:h-(--hairline) after:min-w-4 after:flex-1 after:bg-border data-[align=end]:after:hidden data-[align=start]:before:hidden",
  vertical:
    "flex shrink-0 flex-col items-center gap-3 self-stretch text-xs text-muted-foreground before:min-h-4 before:w-(--hairline) before:flex-1 before:bg-border after:min-h-4 after:w-(--hairline) after:flex-1 after:bg-border data-[align=end]:after:hidden data-[align=start]:before:hidden",
}

const labelAlignClassName: Record<SeparatorAlign, string> = {
  start: "text-start",
  center: "text-center",
  end: "text-end",
}

function mergeClassName<State>(
  base: string,
  className: string | ((state: State) => string | undefined) | undefined
) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function hasContent(children: React.ReactNode) {
  return React.Children.toArray(children).some(
    (child) => typeof child !== "string" || child.trim() !== ""
  )
}

function Separator({
  className,
  orientation = "horizontal",
  decorative = false,
  align = "center",
  children,
  ...props
}: SeparatorProps) {
  const labelled = hasContent(children)
  const semantics = labelled
    ? { role: undefined, "aria-orientation": undefined }
    : decorative
      ? { role: "none", "aria-orientation": undefined }
      : {}

  return (
    <SeparatorPrimitive
      data-slot="separator"
      data-content={labelled ? "" : undefined}
      data-align={labelled ? align : undefined}
      orientation={orientation}
      className={mergeClassName(
        (labelled ? labelledClassName : lineClassName)[
          orientation === "vertical" ? "vertical" : "horizontal"
        ],
        className
      )}
      {...semantics}
      {...props}
    >
      {labelled ? (
        <span
          data-slot="separator-label"
          className={cn(
            "flex max-w-full min-w-0 items-center gap-1.5 text-pretty wrap-anywhere [&>svg]:size-3.5 [&>svg]:shrink-0",
            labelAlignClassName[align]
          )}
        >
          {children}
        </span>
      ) : null}
    </SeparatorPrimitive>
  )
}

export { Separator }
export type { SeparatorAlign, SeparatorProps }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
```

```tsx
<FieldGroup indicator="optional">
  <Field>
    <FieldLabel>Email</FieldLabel>
    <Input type="email" required />
    <FieldDescription>We’ll never share it.</FieldDescription>
    <FieldError />
  </Field>
</FieldGroup>
```

Put any HextaUI control inside a `<Field />` and it’s labelled, described and validated automatically. There’s no need to wire up `id`, `htmlFor` or `aria-describedby` by hand.

## Composition

### Single field

One control with its label, help text and validation.

```text
Field
├── FieldLabel
├── Input / Textarea / Select / …
├── FieldDescription
├── FieldError
├── FieldCounter
└── FieldStatus
```

### Horizontal field

A switch or checkbox with its text beside it.

```text
Field
├── Switch / Checkbox
└── FieldContent
    ├── FieldLabel
    └── FieldDescription
```

### Choice card

A label that wraps a whole field, so the card is the click target.

```text
FieldLabel
└── Field
    ├── Checkbox / RadioGroupItem
    └── FieldContent
        ├── FieldTitle
        └── FieldDescription
```

### Grouped fields

Related fields, spaced evenly.

```text
FieldGroup
├── Field
├── FieldSeparator
└── Field
```

### Fieldsets

A titled group of fields, or a radio or checkbox group with an item per option.

```text
FieldSet
├── FieldLegend
├── FieldDescription
└── FieldGroup
    └── Field

FieldSet
├── FieldLegend
└── FieldItem
    ├── RadioGroupItem / Checkbox
    └── FieldLabel
```

## Examples

### Input

A label, a control and a description. Clicking the label focuses the input, and screen readers read the description after the label.

```tsx title="components/examples/field/input.tsx"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldInput() {
  return (
    <Field className="max-w-sm">
      <FieldLabel>Username</FieldLabel>
      <Input placeholder="ada" autoComplete="username" />
      <FieldDescription>
        Shown on your profile. You can change it once a month.
      </FieldDescription>
    </Field>
  )
}
```

### Validation

Native constraints like `required` and `minLength` are checked on blur. Give each `<FieldError />` a `match` to word the message per problem. An empty required field is only flagged after it has been edited, so tabbing past it doesn’t shout.

```tsx title="components/examples/field/validation.tsx"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldValidation() {
  return (
    <Field validationMode="onBlur" className="max-w-sm">
      <FieldLabel>Password</FieldLabel>
      <Input
        type="password"
        autoComplete="new-password"
        required
        minLength={8}
      />
      <FieldDescription>At least 8 characters.</FieldDescription>
      <FieldError match="valueMissing">Choose a password.</FieldError>
      <FieldError match="tooShort">
        That’s too short. Use 8 or more characters.
      </FieldError>
    </Field>
  )
}
```

### Custom validation

Pass `validate` to check anything, including async lookups. Return a message to fail or nothing to pass. With `validationMode="onChange"` and `validationDebounceTime`, it runs while typing without firing on every key. Try “ada”.

```tsx title="components/examples/field/custom-validation.tsx"
"use client"

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

const taken = ["admin", "ada", "hexta"]

async function checkUsername(value: unknown) {
  const name = String(value ?? "")
    .trim()
    .toLowerCase()
  if (name.length === 0) {
    return null
  }
  if (!/^[a-z0-9_]+$/.test(name)) {
    return "Use letters, numbers and underscores only."
  }
  await new Promise((resolve) => setTimeout(resolve, 400))
  return taken.includes(name) ? `“${name}” is taken.` : null
}

export function FieldCustomValidation() {
  return (
    <Field
      validationMode="onChange"
      validationDebounceTime={300}
      validate={checkUsername}
      className="max-w-sm"
    >
      <FieldLabel>Username</FieldLabel>
      <Input placeholder="Try “ada”" autoComplete="off" />
      <FieldDescription>Checked as you type.</FieldDescription>
      <FieldError />
    </Field>
  )
}
```

### Required and optional

Set `indicator` on a `FieldGroup`, `FieldSet` or `Field` and every label inside marks itself from its control's `required` attribute. `"optional"` tags the fields people can skip, which reads calmer when most fields are required. `"required"` adds an asterisk. The mark is hidden from screen readers because the control already announces it.

```tsx title="components/examples/field/indicator.tsx"
import {
  Field,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldIndicatorDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8">
      <FieldGroup indicator="optional">
        <Field>
          <FieldLabel>Email</FieldLabel>
          <Input type="email" autoComplete="email" required />
        </Field>
        <Field>
          <FieldLabel>Company</FieldLabel>
          <Input autoComplete="organization" />
        </Field>
      </FieldGroup>
      <FieldSeparator />
      <FieldGroup indicator="required">
        <Field>
          <FieldLabel>Card number</FieldLabel>
          <Input inputMode="numeric" autoComplete="cc-number" required />
        </Field>
        <Field>
          <FieldLabel>Billing note</FieldLabel>
          <Input />
        </Field>
      </FieldGroup>
    </div>
  )
}
```

### Status

`<FieldStatus />` draws a check once an edited field passes validation, and shows an alert icon while it fails. It follows the field's `validationMode`, so it never judges a field before validation has run.

```tsx title="components/examples/field/status.tsx"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldStatus,
} from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"

export function FieldStatusDemo() {
  return (
    <Field validationMode="onChange" className="max-w-sm">
      <FieldLabel>Invite code</FieldLabel>
      <InputGroup>
        <InputGroupInput
          name="code"
          pattern="HX-[0-9]{4}"
          placeholder="HX-0000"
          autoComplete="off"
        />
        <InputGroupAddon align="inline-end">
          <FieldStatus />
        </InputGroupAddon>
      </InputGroup>
      <FieldDescription>Try HX-2026.</FieldDescription>
      <FieldError match="patternMismatch">
        Codes look like HX- followed by four digits.
      </FieldError>
    </Field>
  )
}
```

### Character count

`<FieldCounter />` finds the text control in its field and counts against its `maxLength`. It only listens, so typing is never slowed or changed.

```tsx title="components/examples/field/counter.tsx"
import {
  Field,
  FieldCounter,
  FieldDescription,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldCounterDemo() {
  return (
    <Field className="max-w-sm">
      <FieldLabel>Status</FieldLabel>
      <Input defaultValue="Shipping the field rework" maxLength={40} />
      <div className="flex items-baseline justify-between gap-3">
        <FieldDescription>Clears after 24 hours.</FieldDescription>
        <FieldCounter />
      </div>
    </Field>
  )
}
```

### Errors from a form library or server

Pass `invalid` to the field and an `errors` array to `<FieldError />`. It takes the `{ message }` shape React Hook Form and most schema libraries return. Duplicates are dropped, and several messages become a list. When the messages change, the new ones fade in and the height eases to fit, so nothing below jumps. Submit empty, then fix one rule at a time.

```tsx title="components/examples/field/errors.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

type Errors = {
  email?: { message: string }[]
  password?: { message: string }[]
}

export function FieldErrors() {
  const [errors, setErrors] = React.useState<Errors>({})

  return (
    <form
      className="w-full max-w-sm"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        const password = String(data.get("password") ?? "")
        const next: Errors = {}
        if (!String(data.get("email") ?? "").includes("@")) {
          next.email = [{ message: "Enter a valid email." }]
        }
        const problems = []
        if (password.length < 8) {
          problems.push({ message: "Use at least 8 characters." })
        }
        if (!/\d/.test(password)) {
          problems.push({ message: "Include a number." })
        }
        if (problems.length > 0) {
          next.password = problems
        }
        setErrors(next)
      }}
    >
      <FieldGroup>
        <Field invalid={Boolean(errors.email)}>
          <FieldLabel>Email</FieldLabel>
          <Input name="email" autoComplete="email" />
          <FieldError errors={errors.email ?? []} />
        </Field>
        <Field invalid={Boolean(errors.password)}>
          <FieldLabel>Password</FieldLabel>
          <Input name="password" type="password" autoComplete="new-password" />
          <FieldError errors={errors.password ?? []} />
        </Field>
        <Button type="submit">Sign up</Button>
      </FieldGroup>
    </form>
  )
}
```

### Checkboxes

Use `orientation="horizontal"` to put the checkbox beside its label. Inside a `<FieldSet />`, the legend names the whole group.

```tsx title="components/examples/field/checkbox.tsx"
import { Checkbox, CheckboxGroup } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"

const options = [
  { value: "mentions", label: "Mentions" },
  { value: "replies", label: "Replies to my comments" },
  { value: "digest", label: "Weekly digest" },
]

export function FieldCheckbox() {
  return (
    <FieldSet className="w-full max-w-sm">
      <FieldLegend variant="label">Email me about</FieldLegend>
      <FieldDescription>Security alerts are always on.</FieldDescription>
      <CheckboxGroup defaultValue={["mentions", "replies"]}>
        {options.map((option) => (
          <Field key={option.value} orientation="horizontal">
            <Checkbox name="notifications" value={option.value} />
            <FieldLabel>{option.label}</FieldLabel>
          </Field>
        ))}
      </CheckboxGroup>
    </FieldSet>
  )
}
```

### Choice cards

Wrap a whole field in `<FieldLabel />` to make the card the click target. Use `<FieldTitle />` inside, since labels can’t nest. The card tints when checked and shows the focus ring when its checkbox is focused.

```tsx title="components/examples/field/choice-card.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"

export function FieldChoiceCard() {
  return (
    <FieldSet className="w-full max-w-sm">
      <FieldLegend variant="label">Add-ons</FieldLegend>
      <div className="flex flex-col gap-3">
        <FieldLabel>
          <Field orientation="horizontal">
            <Checkbox name="backups" defaultChecked />
            <FieldContent>
              <FieldTitle>Daily backups</FieldTitle>
              <FieldDescription>
                Restore any day from the last 30.
              </FieldDescription>
            </FieldContent>
          </Field>
        </FieldLabel>
        <FieldLabel>
          <Field orientation="horizontal">
            <Checkbox name="support" />
            <FieldContent>
              <FieldTitle>Priority support</FieldTitle>
              <FieldDescription>Replies within four hours.</FieldDescription>
            </FieldContent>
          </Field>
        </FieldLabel>
        <FieldLabel>
          <Field orientation="horizontal" disabled>
            <Checkbox name="sso" />
            <FieldContent>
              <FieldTitle>Single sign-on</FieldTitle>
              <FieldDescription>
                Available on the Business plan.
              </FieldDescription>
            </FieldContent>
          </Field>
        </FieldLabel>
      </div>
    </FieldSet>
  )
}
```

### Fieldset

`<FieldSet />` groups related fields under a `<FieldLegend />`, which becomes the group’s accessible name. Lay fields out side by side with a plain grid.

```tsx title="components/examples/field/fieldset.tsx"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldFieldset() {
  return (
    <FieldSet className="w-full max-w-md">
      <FieldLegend>Shipping address</FieldLegend>
      <FieldDescription>Where should we send your order?</FieldDescription>
      <FieldGroup>
        <Field>
          <FieldLabel>Street address</FieldLabel>
          <Input autoComplete="street-address" />
        </Field>
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
          <Field>
            <FieldLabel>City</FieldLabel>
            <Input autoComplete="address-level2" />
          </Field>
          <Field>
            <FieldLabel>Postal code</FieldLabel>
            <Input autoComplete="postal-code" />
          </Field>
        </div>
      </FieldGroup>
    </FieldSet>
  )
}
```

### Responsive

`orientation="responsive"` stacks the label and control in narrow spaces and puts them side by side once the surrounding `<FieldGroup />` is wide enough. It responds to the group’s width, not the window’s.

```tsx title="components/examples/field/responsive.tsx"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldResponsive() {
  return (
    <FieldGroup className="w-full max-w-xl">
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel>Display name</FieldLabel>
          <FieldDescription>Shown next to your messages.</FieldDescription>
        </FieldContent>
        <Input placeholder="Ada" className="sm:max-w-56" />
      </Field>
      <Field orientation="responsive">
        <FieldContent>
          <FieldLabel>Website</FieldLabel>
          <FieldDescription>Linked from your profile.</FieldDescription>
        </FieldContent>
        <Input placeholder="example.com" className="sm:max-w-56" />
      </Field>
    </FieldGroup>
  )
}
```

### Disabled

Disabling a `<FieldSet />` disables every field and control inside it. Pass `disabled` to a single `<Field />` to disable just that one.

```tsx title="components/examples/field/disabled.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldDisabled() {
  return (
    <FieldSet disabled className="w-full max-w-sm">
      <FieldLegend>Billing details</FieldLegend>
      <FieldDescription>Managed by your organization’s admin.</FieldDescription>
      <FieldGroup>
        <Field>
          <FieldLabel>Company</FieldLabel>
          <Input defaultValue="Acme Inc." />
        </Field>
        <Field orientation="horizontal">
          <Checkbox defaultChecked />
          <FieldLabel>Send invoices by email</FieldLabel>
        </Field>
      </FieldGroup>
    </FieldSet>
  )
}
```

### Long content

Labels, descriptions and errors wrap inside narrow forms, including unbroken strings, and never push the layout wider.

```tsx title="components/examples/field/long-content.tsx"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldLongContent() {
  return (
    <Field invalid className="w-72">
      <FieldLabel>
        A label long enough to wrap onto a second line in a narrow form
      </FieldLabel>
      <Input defaultValue="averyveryverylongunbrokenvaluethatkeepsgoing" />
      <FieldDescription>
        averyveryverylongunbrokendescriptionthatmustwrapinsteadofoverflowing
      </FieldDescription>
      <FieldError
        errors={[
          {
            message:
              "This message is long on purpose and wraps onto several lines without pushing anything wider.",
          },
        ]}
      />
    </Field>
  )
}
```

### Right to left

Text, checkbox placement and error lists follow the reading direction.

```tsx title="components/examples/field/rtl.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldRtl() {
  return (
    <div dir="rtl" className="w-full max-w-sm">
      <FieldGroup>
        <Field invalid>
          <FieldLabel>البريد الإلكتروني</FieldLabel>
          <Input type="email" defaultValue="ada@" />
          <FieldDescription>
            سنرسل رابط التأكيد إلى هذا العنوان.
          </FieldDescription>
          <FieldError errors={[{ message: "أدخل عنوان بريد صالحًا." }]} />
        </Field>
        <Field orientation="horizontal">
          <Checkbox defaultChecked />
          <FieldLabel>تذكرني</FieldLabel>
        </Field>
      </FieldGroup>
    </div>
  )
}
```

## Accessibility

- The label, description and visible errors are linked to the control for you, so screen readers announce all three when it’s focused.
- Invalid controls get `aria-invalid`, which also draws their error ring.
- Errors are not live regions. They’re read when the control is focused, so validating on change doesn’t interrupt typing. On submit, move focus to the first invalid field.
- Errors grow and fade in place instead of pushing content down. With reduced motion on, they appear without animating.

## API reference

Built on the Base UI field and fieldset. Every part accepts the props of the element or primitive it renders.

### Field

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"vertical" \| "horizontal" \| "responsive"` | `"vertical"` |  |
| `indicator` | `"required" \| "optional" \| null` | – | Mark the label from the control's required attribute. Inherited from FieldGroup or FieldSet. |
| `name` | `string` | – | Identifies the field when the form is submitted. |
| `validate` | `(value, formValues) => string \| string[] \| null \| Promise<…>` | – | Return one or more messages to fail, or nothing to pass. Async is supported. |
| `validationMode` | `"onSubmit" \| "onBlur" \| "onChange"` | `"onSubmit"` |  |
| `validationDebounceTime` | `number` | `0` | Milliseconds to wait between onChange validations. |
| `invalid` | `boolean` | – | Set it from a form library or server response. |
| `disabled` | `boolean` | `false` |  |
| `dirty` | `boolean` | – |  |
| `touched` | `boolean` | – |  |
| `actionsRef` | `RefObject<{ validate: () => void }>` | – | Validate the field imperatively. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field"` | Target fields in CSS. |
| `data-orientation` | The current orientation. |
| `data-disabled` | Present when the field is disabled. |
| `data-valid` | Present when the field is valid. |
| `data-invalid` | Present when the field is invalid. |
| `data-dirty` | Present once the value has changed from its initial value. |
| `data-touched` | Present once the control has been focused and left. |
| `data-filled` | Present when the control has a value. |
| `data-focused` | Present while the control has focus. |

### FieldLabel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nativeLabel` | `boolean` | `true` | Set false when render swaps the label for a non-label element. |
| `optionalText` | `ReactNode` | `"Optional"` | Text shown with indicator="optional". |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<label>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-label"` | Target labels in CSS. Outside a field it renders a plain label, which is how choice cards work. |
| `data-disabled` | Present when the field is disabled. |
| `data-valid` | Present when the field is valid. |
| `data-invalid` | Present when the field is invalid. |
| `data-dirty` | Present once the value has changed from its initial value. |
| `data-touched` | Present once the control has been focused and left. |
| `data-filled` | Present when the control has a value. |
| `data-focused` | Present while the control has focus. |

### FieldStatus

An icon that reflects the field's validity. It's decorative, since the error message carries the meaning.

| Attribute | Description |
| --- | --- |
| `data-slot="field-status"` | Target the status icon in CSS. |

### FieldCounter

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `threshold` | `number` | `10% of maxLength, at most 20` |  |
| `announcement` | `(remaining: number) => string` | – | Message for screen readers when the count crosses the threshold or hits the limit. |

| Attribute | Description |
| --- | --- |
| `data-slot="field-counter"` | Target the counter in CSS. |
| `data-state="near" \| "limit"` | Present within the threshold, and at the limit. |

### FieldDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<p>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-description"` | Target descriptions in CSS. |
| `data-disabled` | Present when the field is disabled. |
| `data-valid` | Present when the field is valid. |
| `data-invalid` | Present when the field is invalid. |
| `data-dirty` | Present once the value has changed from its initial value. |
| `data-touched` | Present once the control has been focused and left. |
| `data-filled` | Present when the control has a value. |
| `data-focused` | Present while the control has focus. |

### FieldError

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `match` | `boolean \| "valueMissing" \| "typeMismatch" \| "tooShort" \| "tooLong" \| "patternMismatch" \| "rangeOverflow" \| "rangeUnderflow" \| "stepMismatch" \| "badInput" \| "customError" \| "valid"` | – | Show only for this validity problem. true always shows it. |
| `errors` | `Array<{ message?: string } \| undefined>` | – | Errors from a form library or server. Shown when the list has a message. |
| `children` | `ReactNode` | – | Defaults to the validation message. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-error"` | Target errors in CSS. |
| `data-starting-style` | Present while the error grows in. |
| `data-ending-style` | Present while the error collapses. |
| `data-disabled` | Present when the field is disabled. |
| `data-valid` | Present when the field is valid. |
| `data-invalid` | Present when the field is invalid. |
| `data-dirty` | Present once the value has changed from its initial value. |
| `data-touched` | Present once the control has been focused and left. |
| `data-filled` | Present when the control has a value. |
| `data-focused` | Present while the control has focus. |

### FieldContent

Stacks a label, description and error beside a control in a horizontal field.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

### FieldTitle

A label-styled title for content inside a `<FieldLabel />`, such as choice cards.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

### FieldGroup

Spaces fields apart and is the container that responsive fields measure.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `indicator` | `"required" \| "optional" \| null` | – | Applies to every field inside. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

### FieldSet

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `indicator` | `"required" \| "optional" \| null` | – | Applies to every field inside. |
| `disabled` | `boolean` | `false` | Disables every field inside. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<fieldset>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-set"` | Target fieldsets in CSS. |
| `data-disabled` | Present when the fieldset is disabled. |

### FieldLegend

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"legend" \| "label"` | `"legend"` | label matches the size of a field label. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-legend"` | Target legends in CSS. |
| `data-variant` | The current variant. |

### FieldSeparator

A `<Separator />` spaced for forms, and accepts all of its props.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | Optional text shown in the middle of the line. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Where the text sits along the line. |
| `decorative` | `boolean` | `false` | Hide a plain line from screen readers when it is only visual. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="field-separator"` | Target field separators in CSS. |
| `data-content` | Present when the separator has text. |
| `data-slot="separator-label"` | The element that wraps the text. |

### FieldItem

Wraps one checkbox or radio and its label inside a group, so each item can be disabled on its own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

### FieldValidity

Renders anything from the field’s validity state, for example a strength meter or a character counter.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(state: { validity, errors, error, value }) => ReactNode` | – |  |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
