Skip to content
  1. Keyline UI
  2. Field and form
Keyline UIComponents
  • Get started
    • Installation
    • Theming
    • Modes
    • Configuration
    • Composition
  • Actions
    • Button
    • Button group
    • Split button
    • Toggle
    • Toggle group
    • Toolbar
    • Menu
    • Menubar
    • Context menu
  • Inputs
    • Field and form
    • Checkbox
    • Checkbox group
    • Radio group
    • Switch
    • Slider
    • Segmented
    • Select
    • Native select
    • Combobox
    • Multiselect
    • Number field
    • OTP field
    • Time field
    • Date picker and calendar
    • Date range
  • Data
    • Table
    • Chart
    • Stat and meter
    • Status and badge
    • Progress
    • Skeleton
    • Empty
    • Avatar
    • Kbd
    • Marker
  • Navigation
    • Tabs
    • Breadcrumb
    • Pagination
    • Nav
    • Navigation menu
    • Command menu
  • Overlays
    • Dialog
    • Alert dialog
    • Drawer
    • Sheet
    • Popover
    • Hover card
    • Tooltip
    • Toast
    • Notice
  • Layout
    • App shell
    • Sidebar
    • Panel
    • Resizable
    • Scroll area
    • Accordion
    • Collapsible
    • Carousel
    • Inspector
    • Item
    • Direction
  • Conversation
    • Message
    • Bubble
    • Message scroller
    • Attachment
    • Questionnaire
  • Blocks
    • Shipments

Field and form

A label, a frame, a description and an error for any input, and a form that lines them up. When a check fails, the frame turns red, a ! cell opens at its start and the message says what to do.

StandardNarrow
010020030040050060070080090010001100120013001400150016001700180019002000

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldAffix, FieldDescription, FieldError, FieldFrame, FieldInput, FieldLabel, FieldTextarea, Form, FormActions } from "@/components/keyline/ui/field"

const IN_STOCK = 480

export function NewOrder() {
  const [placed, setPlaced] = useState<string | null>(null)

  return (
    <Form
      orientation="responsive"
      onFormSubmit={(v) => {
        const cases = Number(v.cases)
        setPlaced(`Order DM-4142 placed: ${cases} ${cases === 1 ? "case" : "cases"} for ${v.client}. Darryl has the note.`)
      }}
    >
      <Field name="client">
        <FieldLabel>Client</FieldLabel>
        <FieldFrame>
          <FieldInput required defaultValue="Scranton Business Park" autoComplete="organization" />
        </FieldFrame>
        <FieldError match="valueMissing">Who is the order for?</FieldError>
      </Field>
      <Field name="cases" validate={(value) => (Number(value) > IN_STOCK ? `The warehouse has ${IN_STOCK} cases. Split the order or ask Darryl.` : null)}>
        <FieldLabel>Quantity</FieldLabel>
        <FieldFrame>
          <FieldInput type="number" inputMode="numeric" min={1} required defaultValue="40" align="end" />
          <FieldAffix>cases</FieldAffix>
        </FieldFrame>
        <FieldDescription>20 lb copy paper, 10 reams to a case.</FieldDescription>
        <FieldError />
      </Field>
      <Field name="note">
        <FieldLabel>Delivery note</FieldLabel>
        <FieldFrame>
          <FieldTextarea rows={3} placeholder="Loading dock is round the back." />
        </FieldFrame>
        <FieldDescription>Darryl reads this before he loads the truck.</FieldDescription>
      </Field>
      <FormActions>
        <Button type="submit" variant="primary">
          Place order
        </Button>
      </FormActions>
      <p role="status" data-kl-span="control" className="min-h-5 text-[14px] text-kl-ink-2">
        {placed ?? "Try 500 cases, or clear the client."}
      </p>
    </Form>
  )
}

Installation

npx shadcn@latest add @keyline-ui/field

Run it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.

Usage

import { Form, FormActions, Field, FieldLabel, FieldFrame, FieldInput, FieldError } from "@/components/keyline/ui/field"
<Form onFormSubmit={placeOrder}>
  <Field name="client">
    <FieldLabel>Client</FieldLabel>
    <FieldFrame>
      <FieldInput required />
    </FieldFrame>
    <FieldError match="valueMissing">Who is the order for?</FieldError>
  </Field>
  <FormActions>
    <Button type="submit" variant="primary">Place order</Button>
  </FormActions>
</Form>

Examples

Server errors

Pass your server's answer to errors, keyed by field name, and it shows under that field. The Track button is a layout="cell" Button inside the frame.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldDescription, FieldError, FieldFrame, FieldInput, FieldLabel, Form } from "@/components/keyline/ui/field"

// stands in for your API: only today's shipments exist
const SHIPMENTS: Record<string, string> = {
  "DM-4102": "On Darryl's truck, due at Scranton Business Park by 2 PM.",
  "DM-4087": "Delivered to the Wilkes-Barre Library at 10:14 AM.",
}

export function TrackShipment() {
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [found, setFound] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)

  return (
    <Form
      className="max-w-[440px]"
      errors={errors}
      onFormSubmit={(v) => {
        const id = String(v.tracking).trim().toUpperCase()
        setBusy(true)
        setTimeout(() => {
          setBusy(false)
          if (SHIPMENTS[id]) {
            setErrors({})
            setFound(`${id}: ${SHIPMENTS[id]}`)
          } else {
            setFound(null)
            setErrors({ tracking: `No shipment ${id} today. Try DM-4102.` })
          }
        }, 600)
      }}
    >
      <Field name="tracking">
        <FieldLabel>Tracking number</FieldLabel>
        <FieldFrame>
          <FieldInput required defaultValue="DM-9999" spellCheck={false} className="font-kl-mono" />
          <Button type="submit" layout="cell" signal={busy ? "pending" : "idle"}>
            Track
          </Button>
        </FieldFrame>
        <FieldDescription>Printed on the packing slip.</FieldDescription>
        <FieldError />
      </Field>
      <p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
        {found}
      </p>
    </Form>
  )
}

Several parts

FieldGroup puts several labelled inputs in one frame. A rule across them goes in FieldGroupError, and invalid marks the part that broke it.

Loading the live example…

"use client"

import { useState, type ChangeEvent } from "react"
import { FieldGroup, FieldGroupError, FieldGroupJoin, FieldGroupPart } from "@/components/keyline/ui/field"

const SHELF = 30 // inches under the truck's top shelf

export function CartonSize() {
  const [size, setSize] = useState({ l: "24", w: "18", h: "34" })
  const tall = Number(size.h) > SHELF
  const part = (k: keyof typeof size) => ({
    inputMode: "numeric" as const,
    value: size[k],
    onChange: (e: ChangeEvent<HTMLInputElement>) => setSize({ ...size, [k]: e.target.value }),
  })

  return (
    <div className="grid max-w-[360px] gap-2">
      <FieldGroup legend="Carton size, inches" invalid={tall}>
        <FieldGroupPart label="L" inputProps={part("l")} />
        <FieldGroupJoin />
        <FieldGroupPart label="W" inputProps={part("w")} />
        <FieldGroupJoin />
        <FieldGroupPart label="H" invalid={tall} inputProps={part("h")} />
      </FieldGroup>
      <FieldGroupError>{tall ? `At ${size.h} in, it won't fit under the truck's top shelf. Keep it to ${SHELF} in.` : null}</FieldGroupError>
    </div>
  )
}

Props

PropTypeDefaultDescription
orientation"vertical" | "horizontal" | "responsive"Default "vertical"On Form or a single Field. Responsive puts labels in a start column while the form is at least 560px wide.
onFormSubmit(values) => voidDefault —On Form: runs once every field passes, with the values keyed by each field's name.
errorsRecord<string, string>Default —On Form: messages from your server, keyed by field name. They show in that field's FieldError.
validate(value) => string | nullDefault —On Field: return a message to fail. Runs on submit, then as the value changes.
invalidbooleanDefault falseOn Field: show the error state yourself, for a rule you check elsewhere.
matchboolean | keyof ValidityStateDefault —On FieldError: show this message for one check only, such as valueMissing. Without it, the failed check's own message shows.
layout"control" | "cell"Default "control"On FieldFrame: cell drops its border, to sit inside another frame.

Also exported: FieldTextarea, FieldAffix for a unit or icon cell, FieldBar for a row across the frame, FieldDescription, and the FieldGroup parts. Built on Base UI Field and Form.

Sheet 16 of 73PreviousContext menuNextCheckbox
On this page