{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checkbox",
  "type": "registry:ui",
  "title": "Checkbox and radio",
  "description": "Real checkboxes and radios drawn in the litt look, with the label on the right and the whole row clickable. A group is a fieldset with a legend, and a select-all box can show the mixed state.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/checkbox.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/checkbox 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Checkbox and radio (components.md, \"Checkbox and radio\"), plus a fieldset group for each.\n *\n * - Both are real <input> elements, so the role, Space, the arrow keys, form values and\n *   the indeterminate state come from the browser. Nothing is faked with ARIA.\n * - Two looks, one structure. \"drawn\" (the default) restyles the input into the spec's 20px\n *   chip at the control radius. A radio is a circle, because a rounded square would read as a\n *   checkbox. \"native\" is the spec's other option: the browser's own 16px box, with accent-color\n *   set to --litt-fg. Drawn is the default because only a drawn box can be a cut and take the control\n *   radius, and it looks the same in every browser. See the Checkbox page.\n * - Press (R3, material.md 6): the drawn box and cup are the cut, what you put a mark into: --litt-cut inside the\n *   --litt-line-control edge with the top wall's shade (--litt-cut-shade) for a checkbox, and a deeper cup (--litt-slot, --litt-slot-shade)\n *   for a radio. The cut itself never fills. On, an ink block (--litt-fg) is seated flush in it, 2px in so a rim of the cut shows,\n *   lit only on its top edge, with the tick or dash in --litt-canvas; a chosen radio takes a bead, a round raised block\n *   (--litt-fg, --litt-block-lift). A press sinks the block --litt-press-depth. Never the accent (R6): the one accent fill is the\n *   primary action's, and a form can hold many checked boxes. An error swaps the edge for --litt-danger and keeps the shade.\n *   Disabled, it lies flat on --litt-surface-2 with the hairline, and so does the block and the bead.\n * - Flat is bodies/checkbox.flat.tsx: ink on paper, an outline when off, the ink when on. At the Pill shape the 20px box is a circle; at other shapes it follows --litt-radius-control, and a\n *   radio is always round.\n * - The label sits on the right, --litt-space-2 away, at the small size, and the whole row is\n *   clickable. The label is a real <label for>, stretched over the row, so its accessible\n *   name is only its text and the description is linked with aria-describedby.\n * - Motion: the instant tier. Fill, edge and the mark fade with color. The box never moves.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): checking a drawn box pops it on fun mode's pop\n *   spring and the tick draws itself in, as a pen would. Only on a person's change, and only while it moves: once\n *   still, the box and the tick are exactly the drawn ones above. Reduced motion turns it off.\n * - Touch: rows grow from 36 to 44px tall on coarse pointers.\n * - One contract, three bodies (proposals/worlds.md, Bodies). This file is the contract: the props, the real <input>\n *   with its role, checked and mixed state, the label and description, the groups and the keys. It resolves them once\n *   into ChoiceBodyProps and renders the body the material picks (byWorld) for the drawn look. PressBody, below, is the\n *   default (the cut above). bodies/checkbox.flat.tsx is Flat's recipe, an outline when off and the ink when on.\n *   bodies/checkbox.device.tsx is Device's: a square key in a well that latches down and lights its lamp when checked,\n *   and half lights it when mixed. A radio group there is a row of the same keys, interlocked, so pressing one lets the\n *   last one up. The \"native\" look is the browser's own box in every world. A frame that sets its own look draws its\n *   own body, not <html>'s.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type InputHTMLAttributes,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { animate } from \"motion/react\";\nimport { byWorld, useFun, useMaterial } from \"../../lib/litt/motion\";\nimport { CheckIcon } from \"./icons\";\nimport { ChoiceDeviceBody } from \"./bodies/checkbox.device\";\nimport { ChoiceFlatBody } from \"./bodies/checkbox.flat\";\n\nexport type ChoiceAppearance = \"drawn\" | \"native\";\n\n/* What a group tells its items. Outside a group the defaults apply. */\ntype GroupState = { appearance: ChoiceAppearance; invalid: boolean };\nconst GroupContext = createContext<GroupState>({ appearance: \"drawn\", invalid: false });\n\ntype RadioState = { name: string; value: string | undefined; select: (value: string) => void };\nconst RadioContext = createContext<RadioState | null>(null);\n\n/* ------------------------------------------------------------------ the row */\n\nconst focusRing = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/* Press: the input is the cut, a place you put a mark into. It stays empty and sunk, checked or not. The ink is a separate\n   block seated in it (below). The input sits above the label's stretched click area (z-1), so a click on the chip itself\n   still lands on the input, as test tools expect. A checkbox's cut is a field's cut (--litt-cut, --litt-cut-shade); a radio's is a\n   cup, deeper (--litt-slot, --litt-slot-shade). */\nconst cutBase =\n  \"peer relative z-1 col-start-1 row-start-1 m-0 size-5 shrink-0 cursor-pointer appearance-none self-start \" +\n  \"aria-invalid:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-cut-shade)] \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border \" + // forced colors drops box-shadow, so the edge becomes a real border\n  focusRing;\nconst squareCut = cutBase + \" rounded-litt-control bg-litt-cut shadow-[inset_0_0_0_1px_var(--litt-line-control),var(--litt-cut-shade)]\";\nconst cupCut = cutBase + \" rounded-litt-pill bg-litt-slot shadow-[inset_0_0_0_1px_var(--litt-line-control),var(--litt-slot-shade)]\";\n\n/* Hover and press, as a wash over the cut. It is not tied to the input's own :hover, because the row is the target. */\nconst wash =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 size-5 self-start bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] opacity-0 \" +\n  \"transition-[opacity,background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"group-hover/choice:opacity-100 group-active/choice:bg-[color-mix(in_oklab,var(--litt-fg)_10%,transparent)] group-active/choice:opacity-100 \" +\n  \"peer-disabled:hidden\";\n\n/* The ink block: 16px, seated 2px in the cut so a rim of the cut shows round it, its corners the cut's less those 2px.\n   Lit only on its top edge and casting nothing, because it is flush; under the hand it sinks --litt-press-depth. The mark in it\n   is --litt-canvas. The block is shown by opacity, so it fades on the instant tier and never moves on its own. */\nconst block =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 m-0.5 grid size-4 place-items-center self-start rounded-[calc(var(--litt-radius-control)-2px)] \" +\n  \"bg-litt-fg text-litt-canvas opacity-0 shadow-[inset_0_1px_0_color-mix(in_oklab,white_20%,transparent)] \" +\n  \"transition-[opacity,transform] duration-(--litt-dur-instant) ease-litt \" +\n  \"group-active/choice:translate-y-(--litt-press-depth) \" +\n  \"peer-disabled:bg-litt-surface-3 peer-disabled:text-litt-fg-disabled peer-disabled:shadow-none forced-colors:border\";\n\n/* A chosen radio's bead: a round raised block in the cup, with the block's cast (--litt-block-lift), 12px in a 20px cup. */\nconst bead =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 m-1 size-3 self-start rounded-litt-pill bg-litt-fg opacity-0 shadow-(--litt-block-lift) \" +\n  \"transition-[opacity,transform] duration-(--litt-dur-instant) ease-litt peer-checked:opacity-100 \" +\n  \"group-active/choice:translate-y-(--litt-press-depth) group-active/choice:shadow-(--litt-block-press) \" +\n  \"peer-disabled:bg-litt-fg-disabled peer-disabled:shadow-none forced-colors:bg-[CanvasText]\";\n\nconst nativeInput =\n  \"peer relative z-1 col-start-1 row-start-1 m-0 mt-0.5 size-4 shrink-0 cursor-pointer self-start justify-self-center accent-litt-fg \" +\n  \"disabled:cursor-not-allowed \" +\n  focusRing;\n\n/**\n * What the contract hands a body: the input resolved once. A body draws the control, the <input> (appearance: none)\n * and what sits beside it in the row's first cell; the row, the label and the description stay with the contract. A\n * body keeps the input's props whole (`input`: ref, id, type, checked, handlers, aria-*), says the state without color\n * alone, and carries data-body on the input.\n */\nexport type ChoiceBodyProps = {\n  /** \"checkbox\" has a mixed state; a \"radio\" does not (it matches :indeterminate whenever its group is empty). */\n  kind: \"checkbox\" | \"radio\";\n  /** The input's props, without a className: the body draws it. */\n  input: ComponentProps<\"input\">;\n  /** The tick's ref, which fun mode draws in. A body that has no tick leaves it alone. */\n  tick: Ref<HTMLSpanElement>;\n};\n\n/** Press, the default body: a cut in the plate. Checked, an ink block sits flush in it; a radio is a cup that takes a bead. */\nfunction PressBody({ kind, input, tick }: ChoiceBodyProps) {\n  const radio = kind === \"radio\";\n  return (\n    <>\n      <input {...input} data-body=\"press\" className={radio ? cupCut : squareCut} />\n      <span aria-hidden=\"true\" className={wash + (radio ? \" rounded-litt-pill\" : \" rounded-litt-control\")} />\n      {radio ? (\n        <span aria-hidden=\"true\" className={bead} />\n      ) : (\n        <>\n          <span ref={tick} aria-hidden=\"true\" className={block + \" peer-checked:opacity-100 peer-indeterminate:opacity-0\"}>\n            <CheckIcon />\n          </span>\n          <span aria-hidden=\"true\" className={block + \" peer-indeterminate:opacity-100\"}>\n            <DashGlyph />\n          </span>\n        </>\n      )}\n    </>\n  );\n}\n\n/** The dash for the mixed state: 16px, 1.5px stroke, like the other icons. */\nfunction DashGlyph() {\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M4.5 8h7\" />\n    </svg>\n  );\n}\n\ntype ChoiceProps = Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\"> & {\n  kind: \"checkbox\" | \"radio\";\n  label: ReactNode;\n  description?: ReactNode;\n  invalid?: boolean;\n  indeterminate?: boolean;\n  appearance?: ChoiceAppearance;\n  ref?: Ref<HTMLInputElement>;\n};\n\nfunction Choice({\n  kind,\n  label,\n  description,\n  invalid: invalidProp,\n  indeterminate = false,\n  appearance: appearanceProp,\n  id: idProp,\n  \"aria-describedby\": describedByProp,\n  className = \"\",\n  ref,\n  ...rest\n}: ChoiceProps) {\n  const group = useContext(GroupContext);\n  const appearance = appearanceProp ?? group.appearance;\n  const invalid = invalidProp ?? group.invalid;\n  const auto = useId();\n  const id = idProp ?? auto;\n  const descriptionId = description ? `${id}-description` : undefined;\n\n  /* The mixed state is a DOM property, not an attribute. Set it after every render, because a click clears it. */\n  const input = useRef<HTMLInputElement | null>(null);\n  useEffect(() => {\n    if (input.current && kind === \"checkbox\") input.current.indeterminate = indeterminate;\n  });\n  const setRef = useCallback(\n    (el: HTMLInputElement | null) => {\n      input.current = el;\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) ref.current = el;\n    },\n    [ref],\n  );\n\n  /* Fun mode: a box a person checks pops, and its tick draws in. Inline styles only while it moves, then cleared. */\n  const material = useMaterial();\n  const fun = useFun();\n  const tick = useRef<HTMLSpanElement>(null);\n  const onChange = rest.onChange;\n  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n    onChange?.(e);\n    const box = input.current;\n    const path = tick.current?.querySelector(\"path\");\n    if (!fun || kind !== \"checkbox\" || appearance !== \"drawn\" || !e.target.checked || !box || !path || !tick.current) return;\n    const mark = tick.current;\n    path.setAttribute(\"pathLength\", \"1\");\n    path.style.strokeDasharray = \"1\";\n    animate(path, { strokeDashoffset: [1, 0] }, fun.tick).then(() => {\n      path.removeAttribute(\"pathLength\");\n      path.style.strokeDasharray = \"\";\n      path.style.strokeDashoffset = \"\";\n    });\n    animate([box, mark], { scale: [0.8, 1] }, fun.pop).then(() => {\n      box.style.transform = \"\";\n      mark.style.transform = \"\";\n    });\n  };\n\n  const Body = byWorld(material, { press: PressBody, flat: ChoiceFlatBody, device: ChoiceDeviceBody });\n  const device = material === \"device\";\n  const inputProps: ComponentProps<\"input\"> = {\n    ref: setRef,\n    id,\n    type: kind,\n    \"aria-invalid\": invalid || undefined,\n    \"aria-describedby\": [describedByProp, descriptionId].filter(Boolean).join(\" \") || undefined,\n    ...rest,\n    onChange: handleChange,\n  };\n  return (\n    <div className={\"group/choice relative grid w-fit select-none grid-cols-[auto_1fr] gap-x-2 py-2 pointer-coarse:py-3 \" + className}>\n      {appearance === \"native\" ? (\n        <input {...inputProps} data-body={material} className={nativeInput} />\n      ) : (\n        <Body kind={kind} input={inputProps} tick={tick} />\n      )}\n      {/* The label is stretched over the whole row, so a click anywhere in it toggles the input. */}\n      <label\n        htmlFor={id}\n        className={\n          \"col-start-2 row-start-1 min-h-5 cursor-pointer text-litt-small font-litt-title text-litt-fg after:absolute after:inset-0 peer-disabled:cursor-not-allowed peer-disabled:text-litt-fg-disabled\" +\n          (device ? \" pt-0.5\" : \"\") // Device's key is 24px, so the first line sits 2px lower to meet its middle\n        }\n      >\n        {label}\n      </label>\n      {description && (\n        <p\n          id={descriptionId}\n          /* A disabled box keeps its description readable: it is often what says why the box is off-limits. */\n          className={\"col-start-2 row-start-2 mt-1 text-litt-small \" + (invalidProp ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}\n        >\n          {description}\n        </p>\n      )}\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ checkbox */\n\nexport type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\"> & {\n  /** The label, on the right of the box. Required, because a box alone has no name. The whole row is clickable. */\n  label: ReactNode;\n  /** A line of detail under the label. It is linked to the input with aria-describedby. When you pass invalid on this checkbox, it turns danger color, so it can carry the error message. */\n  description?: ReactNode;\n  /** Shows the mixed state, for a select-all box when only some of its items are on. Clicking it still sets checked. */\n  indeterminate?: boolean;\n  /** Draws the error edge and sets aria-invalid. In a CheckboxGroup this follows the group's error. */\n  invalid?: boolean;\n  /** \"drawn\" restyles the input into the 20px chip and is the default. \"native\" is the browser's own 16px box in the foreground color. */\n  appearance?: ChoiceAppearance;\n  /** The underlying <input>, for focus or a form library. */\n  ref?: Ref<HTMLInputElement>;\n};\n\n/** One checkbox with its label. Use checked and onChange to control it, or defaultChecked to leave it alone. Every other input attribute goes through. */\nexport function Checkbox({ label, description, indeterminate = false, invalid, appearance, ...rest }: CheckboxProps) {\n  return <Choice kind=\"checkbox\" label={label} description={description} indeterminate={indeterminate} invalid={invalid} appearance={appearance} {...rest} />;\n}\n\n/* ---------------------------------------------------------------------- radio */\n\nexport type RadioProps = Omit<InputHTMLAttributes<HTMLInputElement>, \"type\" | \"size\" | \"value\"> & {\n  /** The label, on the right of the circle. Required, because a circle alone has no name. The whole row is clickable. */\n  label: ReactNode;\n  /** What this radio stands for. In a RadioGroup it is what onValueChange gets, and it is the value a form sends. */\n  value: string;\n  /** A line of detail under the label. It is linked to the input with aria-describedby. */\n  description?: ReactNode;\n  /** Draws the error edge and sets aria-invalid. In a RadioGroup this follows the group's error. */\n  invalid?: boolean;\n  /** \"drawn\" restyles the input into the 20px circle and is the default. \"native\" is the browser's own 16px radio in the foreground color. */\n  appearance?: ChoiceAppearance;\n  /** The underlying <input>, for focus or a form library. */\n  ref?: Ref<HTMLInputElement>;\n};\n\n/** One radio with its label. Put two or more in a RadioGroup, which gives them a shared name so the arrow keys move between them. */\nexport function Radio({ label, value, description, invalid, appearance, onChange, ...rest }: RadioProps) {\n  const group = useContext(RadioContext);\n  return (\n    <Choice\n      kind=\"radio\"\n      label={label}\n      value={value}\n      description={description}\n      invalid={invalid}\n      appearance={appearance}\n      {...rest}\n      {...(group\n        ? {\n            name: group.name,\n            checked: group.value === value,\n            onChange: (e: ChangeEvent<HTMLInputElement>) => {\n              onChange?.(e);\n              group.select(value);\n            },\n          }\n        : { onChange })}\n    />\n  );\n}\n\n/* --------------------------------------------------------------------- groups */\n\n/* A fieldset with a legend, the options, and a hint or an error under them. */\nfunction GroupShell({\n  row = false,\n  legend,\n  help,\n  error,\n  disabled,\n  className,\n  children,\n}: {\n  legend: ReactNode;\n  help: ReactNode;\n  error: ReactNode;\n  disabled: boolean | undefined;\n  className: string;\n  children: ReactNode;\n  /** The options run in a row (Device's radio keys, side by side). */\n  row?: boolean;\n}) {\n  const noteId = useId();\n  const note = error ?? help;\n  return (\n    <fieldset disabled={disabled} aria-describedby={note ? noteId : undefined} className={\"m-0 min-w-0 border-0 p-0 \" + className}>\n      <legend className={\"p-0 text-litt-small \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>{legend}</legend>\n      <div className={row ? \"flex flex-wrap gap-x-5\" : \"grid justify-items-start\"}>{children}</div>\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </fieldset>\n  );\n}\n\nexport type CheckboxGroupProps = {\n  /** The group's name, shown above the boxes and read out with each one. Required. */\n  legend: ReactNode;\n  /** A hint under the group. An error replaces it. */\n  help?: ReactNode;\n  /** Shown in danger color under the group and linked to the fieldset. Also marks every box invalid. Say how to fix it. */\n  error?: ReactNode;\n  /** Disables every box in the group, through the fieldset. */\n  disabled?: boolean;\n  /** The look for every box in the group: \"drawn\" or \"native\". A box can still set its own. */\n  appearance?: ChoiceAppearance;\n  /** Extra classes for the fieldset, such as a width. */\n  className?: string;\n  /** The Checkbox elements. */\n  children: ReactNode;\n};\n\n/** Boxes that belong together, in one fieldset with a legend. The group holds no value: each Checkbox keeps its own. */\nexport function CheckboxGroup({ legend, help, error, disabled, appearance = \"drawn\", className = \"\", children }: CheckboxGroupProps) {\n  return (\n    <GroupContext.Provider value={{ appearance, invalid: !!error }}>\n      <GroupShell legend={legend} help={help} error={error} disabled={disabled} className={className}>\n        {children}\n      </GroupShell>\n    </GroupContext.Provider>\n  );\n}\n\nexport type RadioGroupProps = {\n  /** The group's name, shown above the options and read out with each one. Required. */\n  legend: ReactNode;\n  /** The radios' shared name, which is what makes the arrow keys move between them and what a form sends. Made up for you when left out. */\n  name?: string;\n  /** The value of the selected radio, when you control it. Pair it with onValueChange. */\n  value?: string;\n  /** The value that starts selected when you do not control it. */\n  defaultValue?: string;\n  /** Called with the picked radio's value each time the selection changes, by click, tap or arrow key. */\n  onValueChange?: (value: string) => void;\n  /** A hint under the group. An error replaces it. */\n  help?: ReactNode;\n  /** Shown in danger color under the group and linked to the fieldset. Also marks every radio invalid. Say how to fix it. */\n  error?: ReactNode;\n  /** Disables every radio in the group, through the fieldset. */\n  disabled?: boolean;\n  /** The look for every radio in the group: \"drawn\" or \"native\". A radio can still set its own. */\n  appearance?: ChoiceAppearance;\n  /** Extra classes for the fieldset, such as a width. */\n  className?: string;\n  /** The Radio elements. */\n  children: ReactNode;\n};\n\n/** Radios that share one choice, in one fieldset with a legend. Native radios already group by name, so the arrow keys and Tab work without code. */\nexport function RadioGroup({\n  legend,\n  name: nameProp,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  help,\n  error,\n  disabled,\n  appearance = \"drawn\",\n  className = \"\",\n  children,\n}: RadioGroupProps) {\n  const auto = useId();\n  const row = useMaterial() === \"device\";\n  const [inner, setInner] = useState(defaultValue);\n  const value = valueProp ?? inner;\n  const select = (v: string) => {\n    setInner(v);\n    onValueChange?.(v);\n  };\n  return (\n    <RadioContext.Provider value={{ name: nameProp ?? auto, value, select }}>\n      <GroupContext.Provider value={{ appearance, invalid: !!error }}>\n        <GroupShell row={row} legend={legend} help={help} error={error} disabled={disabled} className={className}>\n          {children}\n        </GroupShell>\n      </GroupContext.Provider>\n    </RadioContext.Provider>\n  );\n}\n",
      "target": "components/litt/checkbox.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/checkbox.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/checkbox 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Checkbox and radio, the Device body (proposals/worlds.md, Bodies; material.md, 8): a square key that latches.\n *\n * - A key (--litt-block, --litt-key-lift: its face, a dark rim and a 2px skirt) in a 24px well (--litt-tray, --litt-tray-shade). The key is\n *   20px and 2px in, so its corners are the well's less those 2px and the two stay concentric at every shape. The\n *   input itself is the key, so the role, Space, the arrow keys and the mixed state are the browser's.\n * - Latching: a checked key stays down, sunk the whole --litt-press-depth (2px) onto its skirt with --litt-block-press, and its\n *   lamp lit. A mixed key is down too, with its lamp half lit. Unchecked, the key stands and the lamp is a dim ring.\n *   The lamp is the body's ink (--litt-fg), never the accent (R6): a form can hold many checked keys.\n * - A radio is the same key. A group of them is a row, interlocked: pressing one latches it and lets the last one up,\n *   because the browser unchecks the old one. No glide, no travel.\n * - The hand: press and the key sinks 2px (it ignores fun mode's pop and its tick); release and it latches or lets\n *   go. A change switches in place in three steps on the instant tier (100ms), never a spring; reduced motion zeroes\n *   the tier, so it happens at once.\n * - The lamp never says a state alone: the word is the label beside the key, and the input says it to a screen reader.\n * - Forced colors drops shadows and fills, so the well and the key get real borders, a lit lamp is a system\n *   highlight and an unlit one an outline.\n */\nimport type { CSSProperties } from \"react\";\nimport type { ChoiceBodyProps } from \"../checkbox\";\n\n/* The detent: a change switches in place in three even steps on the instant tier, as a display's segments light. */\nconst STEPS = \"steps(3, end)\";\nconst detent: CSSProperties = { transitionDuration: \"var(--litt-dur-instant)\", transitionTimingFunction: `${STEPS}` };\n\nconst focusRing = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/* The key is the input. It sits above the label's stretched click area (z-1), so a press on it still lands on the input.\n   Latched (checked or mixed), it stays sunk. Under the hand it sinks the same 2px. */\nconst key =\n  \"peer relative z-1 col-start-1 row-start-1 m-0.5 size-5 shrink-0 cursor-pointer appearance-none self-start \" +\n  \"rounded-[calc(var(--litt-radius-control)-2px)] bg-litt-block shadow-(--litt-key-lift) \" +\n  \"transition-[transform,box-shadow,background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"enabled:group-active/choice:[transform:translateY(var(--litt-press-depth))] enabled:group-active/choice:shadow-(--litt-block-press) \" +\n  \"checked:[transform:translateY(var(--litt-press-depth))] checked:shadow-(--litt-block-press) \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"disabled:checked:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border \" +\n  focusRing;\n\n/* Only a checkbox has a mixed state. A radio matches :indeterminate whenever nothing in its group is picked. */\nconst mixed =\n  \" indeterminate:[transform:translateY(var(--litt-press-depth))] indeterminate:shadow-(--litt-block-press)\" +\n  \" disabled:indeterminate:shadow-[inset_0_0_0_1px_var(--litt-line)]\";\n\n/* The well is cut into the body, under the key. An error swaps its floor's edge for --litt-danger. */\nconst well =\n  \"pointer-events-none col-start-1 row-start-1 size-6 self-start rounded-litt-control bg-litt-tray shadow-(--litt-tray-shade) \" +\n  \"peer-enabled:group-hover/choice:bg-[color-mix(in_oklab,var(--litt-tray),var(--litt-fg)_4%)] \" +\n  \"peer-aria-invalid:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-tray-shade)] \" +\n  \"peer-disabled:bg-litt-surface-2 peer-disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border\";\n\n/* The lamp sits on the key's face and sinks with it. Unlit: a dim ring. Lit: the ink, with a little glow. */\nconst lamp =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 grid size-6 place-items-center self-start \" +\n  \"transition-[transform] duration-(--litt-dur-instant) ease-litt \" +\n  \"peer-enabled:group-active/choice:[transform:translateY(var(--litt-press-depth))] \" +\n  \"peer-checked:[transform:translateY(var(--litt-press-depth))] \" +\n  \"[&>span]:size-1.5 [&>span]:rounded-litt-pill [&>span]:shadow-[inset_0_0_0_1px_var(--litt-line-control)] \" +\n  \"[&>span]:transition-[background-color,box-shadow] [&>span]:duration-(--litt-dur-instant) [&>span]:ease-litt \" +\n  \"peer-checked:[&>span]:bg-litt-fg peer-checked:[&>span]:shadow-[0_0_6px_var(--litt-fg)] \" +\n  \"peer-disabled:peer-checked:[&>span]:bg-litt-fg-disabled peer-disabled:peer-checked:[&>span]:shadow-none \" +\n  \"forced-colors:[&>span]:border peer-checked:forced-colors:[&>span]:bg-[Highlight]\";\n\n/* Half lit: the lower half of the lamp is the ink. */\nconst lampMixed =\n  \" peer-indeterminate:[transform:translateY(var(--litt-press-depth))]\" +\n  \" peer-indeterminate:[&>span]:bg-[linear-gradient(to_top,var(--litt-fg)_50%,transparent_50%)]\" +\n  \" peer-disabled:peer-indeterminate:[&>span]:bg-[linear-gradient(to_top,var(--litt-fg-disabled)_50%,transparent_50%)]\" +\n  \" peer-indeterminate:forced-colors:[&>span]:bg-[linear-gradient(to_top,Highlight_50%,transparent_50%)]\";\n\n/** Device, the latching key: a square key in a well that stays down with its lamp lit when checked, half lit when mixed. */\nexport function ChoiceDeviceBody({ kind, input }: ChoiceBodyProps) {\n  const radio = kind === \"radio\";\n  return (\n    <>\n      <input {...input} data-body=\"device\" className={key + (radio ? \"\" : mixed)} style={detent} />\n      <span aria-hidden=\"true\" className={well} />\n      <span aria-hidden=\"true\" className={lamp + (radio ? \"\" : lampMixed)} style={detent}>\n        <span style={detent} />\n      </span>\n    </>\n  );\n}\n",
      "target": "components/litt/bodies/checkbox.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/checkbox.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/checkbox 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Checkbox and radio, the Flat body (proposals/worlds.md, Bodies; material.md, 7): ink on paper.\n *\n * - Off is an outline: transparent inside the --litt-line-control edge, no cut and no shade. On is the ink (--litt-fg) filled,\n *   with the tick in --litt-canvas; a radio is a plain ink disc, with no ring and no dot, because ink means on. Mixed keeps\n *   the outline and sets an ink dash in it. Nothing is lit and nothing casts.\n * - Hover is a grey wash over the box. At the Pill shape the 20px box is a circle; at other shapes it follows\n *   --litt-radius-control, and a radio is always round.\n * - Forced colors drops fills and shadows: the box keeps a real border, the tick and the dash take the system text\n *   color, and a radio gets a system-color dot that exists only there, so on is never lost.\n */\nimport { CheckIcon } from \"../icons\";\nimport type { ChoiceBodyProps } from \"../checkbox\";\n\nconst focusRing = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/* The input is the box. The wash and the mark sit in the same grid cell. The input sits above the label's stretched\n   click area (z-1), so a click on the box itself still lands on the input. */\nconst box =\n  \"peer relative z-1 col-start-1 row-start-1 m-0 size-5 shrink-0 cursor-pointer appearance-none self-start \" +\n  \"bg-transparent shadow-[inset_0_0_0_1px_var(--litt-line-control)] \" +\n  \"transition-[background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"aria-invalid:shadow-[inset_0_0_0_1px_var(--litt-danger)] \" +\n  \"disabled:cursor-not-allowed disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border \" + // forced colors drops box-shadow, so the edge becomes a real border\n  focusRing;\n\n/* On is the ink and nothing round it. A checkbox a page sets both checked and mixed stays an outline. */\nconst onCheckbox =\n  \" checked:not-indeterminate:bg-litt-fg checked:not-indeterminate:shadow-none aria-invalid:checked:not-indeterminate:shadow-[inset_0_0_0_1px_var(--litt-danger)] disabled:checked:not-indeterminate:bg-litt-surface-3\";\n/* A radio matches :indeterminate whenever nothing in its group is picked, so it only reads :checked. */\nconst onRadio = \" checked:bg-litt-fg checked:shadow-none aria-invalid:checked:shadow-[inset_0_0_0_1px_var(--litt-danger)] disabled:checked:bg-litt-surface-3\";\n\n/* Hover and press, as a grey wash over the box. It is not tied to the input's own :hover, because the row is the target. */\nconst wash =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 size-5 self-start bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] opacity-0 \" +\n  \"transition-[opacity,background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"group-hover/choice:opacity-100 group-active/choice:bg-[color-mix(in_oklab,var(--litt-fg)_10%,transparent)] group-active/choice:opacity-100 \" +\n  /* A checked box is --litt-fg, where an --litt-fg wash would not show, so the wash there is --litt-canvas. */\n  \"peer-checked:bg-[color-mix(in_oklab,var(--litt-canvas)_12%,transparent)] group-active/choice:peer-checked:bg-[color-mix(in_oklab,var(--litt-canvas)_20%,transparent)] \" +\n  \"peer-disabled:hidden\";\n/* A mixed box is an outline, so its wash stays the grey one, even if a page also sets it checked. */\nconst mixedWash =\n  \" peer-indeterminate:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] group-active/choice:peer-indeterminate:bg-[color-mix(in_oklab,var(--litt-fg)_10%,transparent)]\";\n\n/* A mark is centered in the box, and fades with the fill. */\nconst mark =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 grid size-5 place-items-center self-start opacity-0 \" +\n  \"transition-opacity duration-(--litt-dur-instant) ease-litt peer-disabled:text-litt-fg-disabled\";\n\n/* In forced colors the radio's fill is gone, so a dot in the system text color is the only mark of on. Elsewhere it is not drawn. */\nconst forcedDot =\n  \"pointer-events-none relative z-1 col-start-1 row-start-1 hidden size-5 place-items-center self-start \" +\n  \"forced-colors:peer-checked:grid after:size-2 after:rounded-litt-pill after:bg-[CanvasText]\";\n\n/** The dash for the mixed state: 16px, 1.5px stroke, like the other icons. */\nfunction DashGlyph() {\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M4.5 8h7\" />\n    </svg>\n  );\n}\n\n/** Flat, ink on paper: an outline when off, the ink when on, an ink dash when mixed, a plain disc for a radio. */\nexport function ChoiceFlatBody({ kind, input, tick }: ChoiceBodyProps) {\n  const radio = kind === \"radio\";\n  return (\n    <>\n      <input {...input} data-body=\"flat\" className={box + (radio ? \" rounded-litt-pill\" + onRadio : \" rounded-litt-control\" + onCheckbox)} />\n      <span aria-hidden=\"true\" className={wash + (radio ? \" rounded-litt-pill\" : \" rounded-litt-control\" + mixedWash)} />\n      {radio ? (\n        <span aria-hidden=\"true\" className={forcedDot} />\n      ) : (\n        <>\n          <span ref={tick} aria-hidden=\"true\" className={mark + \" text-litt-canvas peer-checked:opacity-100 peer-indeterminate:opacity-0\"}>\n            <CheckIcon />\n          </span>\n          <span aria-hidden=\"true\" className={mark + \" text-litt-fg peer-indeterminate:opacity-100\"}>\n            <DashGlyph />\n          </span>\n        </>\n      )}\n    </>\n  );\n}\n",
      "target": "components/litt/bodies/checkbox.flat.tsx"
    }
  ],
  "docs": "Import Checkbox, CheckboxGroup, Radio and RadioGroup from components/litt/checkbox. They are native inputs, so name and value work in a form. Pass appearance=\"native\" for the browser's own 16px box.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
