Inkbox

Component

Field

A field's label, control, description and error stacked 4px apart, fields 12px apart, with the 'or' hairline between two ways in.

npx -y github:anwarlemu/inkbox-brand-cli#release add field
Preview
or
Description

Enter a vault key or generate a random one. You'll need this to unlock the vault.

Invalid
Will be displayed as@acme-support
Horizontal
Retention

Days to keep delivered messages before they are deleted.

Fieldset
Webhook destination

Inkbox signs every delivery with the key for this agent identity.

Usage

Wrap every form control in Field with a FieldLabel above it; add FieldDescription for a hint (Enter a vault key or generate a random one) and FieldError, with data-invalid on the Field and aria-invalid on the control, when the value is rejected. Group fields in FieldGroup, related choices in FieldSet with a FieldLegend. Put an action beside the label (Forgot password?) in a row with justify-between. Use orientation horizontal for a label beside its control, and FieldSeparator with "or" between a form and other ways in.

Avoid

  • A red label on an invalid field
  • A placeholder as the only label
  • Errors larger than 12px
  • Fields closer than 12px

Exports: Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle. This component is a client component.

Dependencies: label, class-variance-authority.

Example

examples/field.tsx
import { Button } from "@/components/ui/button";import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle } from "@/components/ui/field";import { Input } from "@/components/ui/input";​export function Preview() {  return (    <form className="mx-auto w-full max-w-86">      <FieldGroup>        <Field>          <FieldLabel htmlFor="login-email">Email address</FieldLabel>          <Input id="login-email" type="email" placeholder="your@email.com" autoComplete="email" />        </Field>        <Field>          <div className="flex items-center justify-between gap-3">            <FieldLabel htmlFor="login-password">Password</FieldLabel>            <a href="#forgot" className="text-body leading-body text-fg-brand underline-offset-4 hover:underline">              Forgot password?            </a>          </div>          <Input id="login-password" type="password" autoComplete="current-password" />        </Field>        <Button type="submit" className="w-full">Log in</Button>        <FieldSeparator>or</FieldSeparator>        <Button variant="secondary" className="w-full">Continue with Google</Button>      </FieldGroup>    </form>  );}​export function Description() {  return (    <Field className="max-w-162.5">      <FieldLabel htmlFor="vault-key">Vault key</FieldLabel>      <FieldDescription>Enter a vault key or generate a random one. You&apos;ll need this to unlock the vault.</FieldDescription>      <Input id="vault-key" type="password" defaultValue="correct-horse-battery" />    </Field>  );}​export function Invalid() {  return (    <Field data-invalid="true" className="max-w-100">      <FieldLabel htmlFor="handle">Agent handle</FieldLabel>      <Input id="handle" defaultValue="acme-support" aria-invalid aria-describedby="handle-error" />      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption leading-body text-fg">        Will be displayed as        <span className="rounded-sm bg-subtle px-1.5 py-0.5">@acme-support</span>        <FieldError id="handle-error">(is not available)</FieldError>      </div>    </Field>  );}​export function Horizontal() {  return (    <FieldGroup className="max-w-120">      <Field orientation="horizontal">        <FieldContent>          <FieldTitle>Retention</FieldTitle>          <FieldDescription>Days to keep delivered messages before they are deleted.</FieldDescription>        </FieldContent>        <Input aria-label="Retention in days" type="number" defaultValue={30} className="w-24" />      </Field>      <Field orientation="responsive">        <FieldLabel htmlFor="display-name">Display name</FieldLabel>        <Input id="display-name" placeholder="Agent display name" className="@md/field-group:w-64" />      </Field>    </FieldGroup>  );}​export function Fieldset() {  return (    <FieldSet className="max-w-120">      <FieldLegend>Webhook destination</FieldLegend>      <FieldDescription>Inkbox signs every delivery with the key for this agent identity.</FieldDescription>      <FieldGroup>        <Field>          <FieldLabel htmlFor="webhook-url">Endpoint URL</FieldLabel>          <Input id="webhook-url" type="url" placeholder="https://example.com/inkbox" />        </Field>        <Field data-invalid="true">          <FieldLabel htmlFor="webhook-secret">Signing secret</FieldLabel>          <Input id="webhook-secret" aria-invalid defaultValue="short" />          <FieldError errors={[{ message: "Use at least 32 characters." }, { message: "Include letters and numbers." }]} />        </Field>      </FieldGroup>    </FieldSet>  );}

Source

components/ui/field.tsx
"use client";​import * as React from "react";import { cva, type VariantProps } from "class-variance-authority";​import { Label } from "@/components/ui/label";import { cn } from "@/lib/utils";​// A form's fields as the console lays them out (Figma 427:2715, Login, Vault, Create Agent Identity). A field is its// 14px label on a 24px line, 4px above the control; fields sit 12px apart. A description under it is 14px muted// (Vault key: "Enter a vault key or generate a random one"); an error is 12px red (is not available). "or" between two// ways in is 14px muted on a hairline. A field marked data-invalid keeps its label ink: only the control and the// message turn red.function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {  return <fieldset data-slot="field-set" className={cn("m-0 flex min-w-0 flex-col gap-4 border-0 p-0 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)} {...props} />;}​function FieldLegend({ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {  return (    <legend      data-slot="field-legend"      data-variant={variant}      className={cn("mb-1 p-0 font-sans text-fg data-[variant=label]:text-body data-[variant=label]:leading-body data-[variant=label]:font-normal data-[variant=legend]:text-body-lg data-[variant=legend]:leading-body data-[variant=legend]:font-medium", className)}      {...props}    />  );}​function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {  return <div data-slot="field-group" className={cn("group/field-group @container/field-group flex w-full flex-col gap-3 [&>[data-slot=field-group]]:gap-4", className)} {...props} />;}​const fieldVariants = cva("group/field flex w-full gap-1 data-[disabled=true]:opacity-50", {  variants: {    orientation: {      // Label over control (Email address over its field).      vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",      // Label beside its control (a channel row and its switch, a checkbox and its label).      horizontal: "flex-row items-center gap-3 [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],&>[role=radio]]:mt-0.5",      // Over on a narrow form, beside from 28rem of container.      responsive: "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto",    },  },  defaultVariants: { orientation: "vertical" },});​function Field({ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {  return <div role="group" data-slot="field" data-orientation={orientation} className={cn(fieldVariants({ orientation }), className)} {...props} />;}​function FieldContent({ className, ...props }: React.ComponentProps<"div">) {  return <div data-slot="field-content" className={cn("group/field-content flex min-w-0 flex-1 flex-col gap-0.5 leading-body", className)} {...props} />;}​// A label that wraps a whole field becomes a choice card: the field edge and 8px corners, 14px in; the checked one takes// the purple edge on the lilac.function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {  return (    <Label      data-slot="field-label"      className={cn(        "group/field-label peer/field-label flex w-fit gap-2 leading-body group-data-[disabled=true]/field:opacity-50",        "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-control has-[>[data-slot=field]]:border has-[>[data-slot=field]]:border-line-field has-[>[data-slot=field]]:transition-[background-color,border-color] has-[>[data-slot=field]]:duration-150 has-[>[data-slot=field]]:ease-out *:data-[slot=field]:p-3.5 has-[>[data-slot=field]]:hover:bg-subtle has-data-[state=checked]:border-line-brand has-data-[state=checked]:bg-subtle motion-reduce:transition-none",        className,      )}      {...props}    />  );}​function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {  return <div data-slot="field-title" className={cn("flex w-fit items-center gap-2 font-sans text-body leading-body font-medium text-fg group-data-[disabled=true]/field:opacity-50", className)} {...props} />;}​function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {  return (    <p      data-slot="field-description"      className={cn(        "m-0 font-sans text-body leading-body font-normal text-fg-muted group-has-[[data-orientation=horizontal]]/field:text-balance [[data-variant=legend]+&]:-mt-1 [&>a]:text-fg-brand [&>a]:underline-offset-4 [&>a:hover]:underline",        className,      )}      {...props}    />  );}​/** The "or" between two ways in: a hairline across the form with a 14px muted word on the page color. */function FieldSeparator({ children, className, ...props }: React.ComponentProps<"div"> & { children?: React.ReactNode }) {  return (    <div data-slot="field-separator" data-content={!!children} className={cn("relative flex h-6 items-center text-body leading-body", className)} {...props}>      <div aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-line-subtle" />      {children && (        <span data-slot="field-separator-content" className="relative mx-auto block w-fit bg-surface px-3 text-fg-muted">          {children}        </span>      )}    </div>  );}​function FieldError({ className, children, errors, ...props }: React.ComponentProps<"div"> & { errors?: Array<{ message?: string } | undefined> }) {  const content = React.useMemo(() => {    if (children) return children;    if (!errors?.length) return null;    const unique = [...new Map(errors.map((error) => [error?.message, error])).values()];    if (unique.length === 1) return unique[0]?.message;    return <ul className="m-0 flex list-disc flex-col gap-0.5 pl-4">{unique.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}</ul>;  }, [children, errors]);  if (!content) return null;  return (    <div role="alert" data-slot="field-error" className={cn("font-sans text-caption leading-body font-normal text-danger", className)} {...props}>      {content}    </div>  );}​export { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle };

Reference: Figma 427:2715: Login (Email address, Password, Forgot password?, or), Vault (Vault key and its description), Create Agent Identity (is not available, Will be displayed as).