{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "option-picker",
  "type": "registry:ui",
  "title": "Option picker",
  "description": "One option of a product (a size, a frame, a color) as native radios with a visible legend that names the pick. Words take the segmented track, colors and images take chips with a check in black or white. A sold-out value stays pickable and says so in words.",
  "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/option-picker.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/option-picker 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Option picker: one option of a product, such as its size or its frame, as a choice of its values (components.md,\n * \"Option picker\"). A fieldset of native radios, so the role, the arrow keys and the form value come from the browser.\n *\n * - Legend: visible. The option's name at the small size and the title weight in --litt-fg, then the picked value's name in\n *   --litt-fg-subtle (\"Frame · Natural oak\"), so a swatch's name is always written somewhere. The picked part is hidden from\n *   screen readers, which hear it on the checked radio instead.\n * - Text values are blocks in a tray in Press (R3: --litt-tray, --litt-tray-shade, --litt-block, --litt-block-lift) and grey pills in Flat.\n *   Keep labels short; a long list of values is a Select.\n * - Swatch values (every value has a color or an image): in Press a 40px block (44 on a touch screen) in the tray; in Flat\n *   a 24px circle (32 on a touch screen) with an fg-12% hairline, since the product's color needs an edge. The color is the product's, not litt's: content,\n *   like a photograph, never a data hue and never chrome. It fills only the chip. The picked Press block carries a 16px\n *   check in black or white, whichever reads better on that color by APCA, as the Color picker's ink; the picked Flat circle is ringed in ink.\n * - Sold out: a value with `available: false` stays pickable, so picking it tells the person why they cannot buy it.\n *   Its label steps back (a chip fades), it says \"sold out\" in hidden text, and while it is\n *   picked the legend says \"Sold out\" after its name. Flat also strikes the word and says \"sold out\" beside it.\n * - Focus by modality: the radio's :focus-visible draws the 2px ring, inset on a segment, at a 2px offset on a chip.\n * - Bodies (proposals/worlds.md): this file keeps the contract (the fieldset, the legend, the radios' names and the\n *   pick) and resolves it once into OptionPickerBodyProps. It renders the body the material picks (byWorld). Press, below,\n *   is the instrument: one tray cut into the page with a block per value, the chosen one raised and lit, the rest seated,\n *   a sold-out one lying flat as a cut in the floor, and a color a small block of its own. Flat\n *   (bodies/option-picker.flat.tsx) is the page: words in grey pills, the chosen in an ink pill, a sold-out value\n *   outlined, struck through and said in words, and colors as plain circles with the chosen one ringed in ink. Device\n *   (bodies/option-picker.device.tsx) is a row of latching keys, each with a lamp. The radios, the arrow keys and every\n *   accessible name are the same in all three.\n * - Motion: nothing glides in Press or Flat; a block rises and sinks where it stands, and colors change on the instant tier.\n */\nimport { useEffect, useId, useState } from \"react\";\nimport { byWorld, useFun, useMaterial, useTransition } from \"../../lib/litt/motion\";\nimport { CheckIcon } from \"./icons\";\nimport { OptionPickerDeviceBody } from \"./bodies/option-picker.device\";\nimport { OptionPickerFlatBody } from \"./bodies/option-picker.flat\";\n\n/** One value of an option, as a product lists it. */\nexport type OptionValue = {\n  /** The value itself, as the store names it: \"A2\", \"Natural oak\". It is what onValueChange gets and a form sends. */\n  value: string;\n  /** What the picker shows, when it differs from the value. */\n  label?: string;\n  /** A color (any CSS color, such as Shopify's hex) or an image for a swatch chip. Values with swatches show as chips. */\n  swatch?: { color?: string; image?: string };\n  /** False when no variant with this value is in stock among the other picks. It stays pickable and says so. */\n  available?: boolean;\n};\n\n/** A product's option: its name and its values, in order. */\nexport type ProductOption = {\n  /** The option's name: \"Size\", \"Frame\". */\n  name: string;\n  values: OptionValue[];\n};\n\nexport type OptionPickerProps = {\n  /** The option's name, shown in the legend: \"Size\". */\n  name: string;\n  /** The values, in the store's order. */\n  values: OptionValue[];\n  /** The picked value, when you control it. Pair it with onValueChange. */\n  value?: string;\n  /** The value that starts picked when you do not control it. Without one, the first value starts picked. */\n  defaultValue?: string;\n  /** Called with the value each time the pick changes, by click, tap or arrow key. */\n  onValueChange?: (value: string) => void;\n  /** The radios' shared name, which a form sends the value under. Made up for you when left out. */\n  inputName?: string;\n  /** Extra classes for the fieldset. */\n  className?: string;\n};\n\n/**\n * What the contract hands a body: the option resolved once. A body draws the values (the radios and what shows them);\n * the fieldset and the legend stay with the contract. A body keeps one native radio per value (checked, named\n * `group`, onChange `onPick`), names a sold-out value \", sold out\" in hidden text, keeps a 44px target, and carries\n * data-body on its root.\n */\nexport type OptionPickerBodyProps = {\n  /** The radios' shared name. */\n  group: string;\n  values: OptionValue[];\n  /** The picked value. */\n  value: string | undefined;\n  /** Every value has a color or an image, so each shows as a chip. */\n  swatches: boolean;\n  /** Picks a value: the radio's change. */\n  onPick: (value: string) => void;\n  /** What a value shows: its label, else the value. */\n  labelOf: (value: OptionValue) => string;\n  /** The ink of the check on a chip, black or white, once the color can be read. */\n  inkOf: (value: OptionValue) => \"black\" | \"white\" | undefined;\n  /** The text track's gliding transition: the responsive tier, or fun mode's glide. */\n  glide: ReturnType<typeof useTransition>;\n  /** The id the text track's shared surface is scoped to. */\n  groupId: string;\n};\n\n/* ----------------------------------------------------------------- the ink on a swatch */\n\n/** Reads a color into its red, green and blue, 0 to 255: hex and the rgb function at once, any other CSS color through a canvas. */\nfunction rgbOf(color: string): [number, number, number] | null {\n  const c = color.trim();\n  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c);\n  if (hex) {\n    const h = hex[1]!.length === 3 ? hex[1]!.replace(/./g, (x) => x + x) : hex[1]!;\n    const n = Number.parseInt(h, 16);\n    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n  }\n  const rgb = /^rgba?\\(\\s*(\\d+)[\\s,]+(\\d+)[\\s,]+(\\d+)/i.exec(c);\n  if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];\n  if (typeof document === \"undefined\") return null;\n  /* A named color or a color function: the canvas reads it back as hex. */\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  if (!ctx) return null;\n  ctx.fillStyle = c;\n  return /^#/.test(ctx.fillStyle) ? rgbOf(ctx.fillStyle) : null;\n}\n\n/** APCA's screen luminance (0.0.98G) of 0-255 channels: sRGB to the 2.4 power, without the WCAG 2 linear toe. */\nconst screenY = ([r, g, b]: [number, number, number]) => 0.2126729 * (r / 255) ** 2.4 + 0.7151522 * (g / 255) ** 2.4 + 0.072175 * (b / 255) ** 2.4;\n\n/** APCA lightness contrast (Lc) of text on a background, as a magnitude. */\nfunction apca(text: number, bg: number) {\n  const soft = (y: number) => (y > 0.022 ? y : y + (0.022 - y) ** 1.414);\n  const t = soft(text);\n  const b = soft(bg);\n  const s = b > t ? (b ** 0.56 - t ** 0.57) * 1.14 : (t ** 0.62 - b ** 0.65) * 1.14;\n  return s < 0.1 ? 0 : (s - 0.027) * 100;\n}\n\n/**\n * Black or white, whichever reads better on the color, chosen by APCA as the Color picker's getContrastInk is: on a mid\n * tone such as a rust, white reads twice as well as black, where the WCAG 2 ratio would call them even and pick black.\n */\nfunction inkOn(color: string | undefined): \"black\" | \"white\" | undefined {\n  const rgb = color ? rgbOf(color) : null;\n  if (!rgb) return undefined;\n  const bg = screenY(rgb);\n  return apca(1, bg) >= apca(0, bg) ? \"white\" : \"black\";\n}\n\n/* ----------------------------------------------------------------- the bodies */\n\n/* Press: a block seated in the tray (sunk the press depth, the cast tight), a block raised and lit (the cast wide, the\n   top edge lit), and a value that is sold out lying flat: no block at all, a cut in the tray floor. */\nconst seated = \"bg-[color-mix(in_oklab,var(--litt-block)_45%,var(--litt-tray))] shadow-(--litt-block-press) [transform:translateY(var(--litt-press-depth))]\";\nconst raised = \"bg-litt-block shadow-[var(--litt-highlight),var(--litt-block-lift)]\";\nconst lying = \"bg-litt-cut shadow-(--litt-cut-shade)\";\nconst tweens = \"transition-[color,background-color,transform] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent]\";\nconst hold = \"hover:bg-litt-block active:shadow-(--litt-block-press) active:[transform:translateY(var(--litt-press-depth))]\";\n\n/**\n * Press, the instrument: one tray cut into the page, and every value a block standing in it. The block you chose is\n * raised and lit; the others sit seated in the tray; a sold-out value lies flat, a cut in the floor. A color is a small\n * block of its own, the color its face. Nothing glides: a block rises and sinks where it stands.\n */\nfunction PressBody({ group, values, value, swatches, onPick, labelOf, inkOf }: OptionPickerBodyProps) {\n  return (\n    <div\n      data-body=\"press\"\n      className={\"flex w-fit max-w-full flex-wrap rounded-litt-control bg-litt-tray p-1 shadow-(--litt-tray-shade) forced-colors:border \" + (swatches ? \"gap-1.5 pointer-coarse:gap-2\" : \"gap-1.5\")}\n    >\n      {values.map((v) => {\n        const on = v.value === value;\n        const out = v.available === false;\n        const radio = (\n          <input type=\"radio\" name={group} value={v.value} checked={on} onChange={() => onPick(v.value)} className=\"peer sr-only\" />\n        );\n        if (swatches) {\n          const ink = inkOf(v);\n          return (\n            <label key={v.value} className=\"litt-target relative block cursor-pointer\">\n              {radio}\n              <span className=\"sr-only\">\n                {labelOf(v)}\n                {out && \", sold out\"}\n              </span>\n              <span\n                aria-hidden=\"true\"\n                className={\n                  \"relative grid size-10 place-items-center overflow-hidden rounded-[calc(var(--litt-radius-control)-4px)] bg-cover bg-center pointer-coarse:size-11 forced-colors:border \" +\n                  tweens +\n                  (on ? \" forced-colors:border-2\" : \"\") +\n                  \" peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-litt-focus\"\n                }\n                style={{\n                  backgroundColor: v.swatch?.color,\n                  backgroundImage: v.swatch?.image ? `url(\"${v.swatch.image}\")` : undefined,\n                  color: ink ?? \"var(--litt-fg)\",\n                }}\n              >\n                {/* The face is the product's color. The block's edge and cast sit over it, and a sold-out block loses both. */}\n                <span\n                  className={\n                    \"pointer-events-none absolute inset-0 rounded-[calc(var(--litt-radius-control)-4px)] \" +\n                    (out ? \"bg-litt-canvas/60 shadow-(--litt-cut-shade)\" : on ? \"shadow-[var(--litt-highlight),var(--litt-block-lift)]\" : \"shadow-(--litt-block-press)\")\n                  }\n                />\n                {on && <CheckIcon className=\"relative\" />}\n              </span>\n            </label>\n          );\n        }\n        return (\n          <label\n            key={v.value}\n            className={\n              \"litt-target relative grid h-litt-control-md min-w-14 cursor-pointer select-none place-items-center rounded-[calc(var(--litt-radius-control)-4px)] px-litt-control-pad-md text-litt-small font-litt-title \" +\n              tweens +\n              \" \" +\n              (out ? lying : on ? raised : seated + \" \" + hold) +\n              \" \" +\n              (out ? (on ? \"text-litt-fg-subtle\" : \"text-litt-fg-disabled\") : on ? \"text-litt-fg\" : \"text-litt-fg-subtle hover:text-litt-fg\") +\n              (on ? \" forced-colors:border-2\" : \" forced-colors:border\")\n            }\n          >\n            {radio}\n            <span className=\"litt-trim relative z-1 truncate\">\n              {labelOf(v)}\n              {out && <span className=\"sr-only\">, sold out</span>}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 z-1 rounded-[calc(var(--litt-radius-control)-4px)] peer-focus-visible:outline-2 peer-focus-visible:-outline-offset-2 peer-focus-visible:outline-litt-focus\"\n            />\n          </label>\n        );\n      })}\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- the picker */\n\n/** One option of a product as a choice of its values: a segmented track for words, chips for colors and images. */\nexport function OptionPicker({\n  name,\n  values,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  inputName,\n  className = \"\",\n}: OptionPickerProps) {\n  const auto = useId();\n  const group = inputName ?? auto;\n  const responsive = useTransition(\"responsive\");\n  const fun = useFun();\n  const glide = fun ? fun.glide : responsive;\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: OptionPickerFlatBody, device: OptionPickerDeviceBody });\n  const [inner, setInner] = useState<string | undefined>(defaultValue ?? values[0]?.value);\n  const value = valueProp ?? inner;\n  const picked = values.find((v) => v.value === value);\n  const swatches = values.length > 0 && values.every((v) => v.swatch?.color || v.swatch?.image);\n  /* A named color is read through a canvas, which the server does not have, so chips draw their check's ink after hydration. */\n  const [hydrated, setHydrated] = useState(false);\n  useEffect(() => setHydrated(true), []);\n\n  const pick = (v: string) => {\n    setInner(v);\n    onValueChange?.(v);\n  };\n  const labelOf = (v: OptionValue) => v.label ?? v.value;\n  const inkOf = (v: OptionValue) => (hydrated || /^(#|rgb)/i.test(v.swatch?.color ?? \"\") ? inkOn(v.swatch?.color) : undefined);\n\n  return (\n    <fieldset className={\"m-0 min-w-0 border-0 p-0 \" + className}>\n      <legend className=\"mb-2 p-0 text-litt-small\">\n        <span className=\"font-litt-title text-litt-fg\">{name}</span>\n        {picked && (\n          <span aria-hidden=\"true\" className=\"text-litt-fg-subtle\">\n            {\" · \"}\n            {labelOf(picked)}\n            {picked.available === false && \" · Sold out\"}\n          </span>\n        )}\n      </legend>\n\n      <Body\n        group={group}\n        values={values}\n        value={value}\n        swatches={swatches}\n        onPick={pick}\n        labelOf={labelOf}\n        inkOf={inkOf}\n        glide={glide}\n        groupId={auto}\n      />\n    </fieldset>\n  );\n}\n",
      "target": "components/litt/option-picker.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/option-picker.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/option-picker 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Option picker, the Device body (proposals/worlds.md, Bodies; material.md, 8): a bank of latching keys.\n *\n * - Words: the values are a row of keys (--litt-block, --litt-key-lift: a face, a dark rim and a 2px skirt) in one well\n *   (--litt-tray, --litt-tray-shade), equal in width. A key's corners are the well's less its 2px inset, so the two stay\n *   concentric at every shape. Its legend is engraved (litt-legend, --litt-engrave) in the mono at the caption size.\n * - Latching: the picked key stays down, as a bank of radio keys does. It sinks the whole --litt-press-depth (2px) onto\n *   its skirt, takes --litt-block-press, and its lamp lights (--litt-fg on the body). The others stand, their lamps a dim ring.\n *   Picking another lets the first up and takes the new one down. Under the hand an unpicked key sinks the same 2px\n *   before it latches. It always travels 2px, and ignores fun mode's deeper sink.\n * - Sold out: the key lies flat in the well (--litt-surface-2, a hairline, no skirt and no sink) and says \"sold out\" in\n *   words on its face. It stays a radio you can pick, as the contract has it, so the reason is found; picked, its lamp\n *   lights and its legend steps up to --litt-fg-subtle, and the contract's legend says Sold out.\n * - Swatches: each chip stands in a small key of its own, the chip inset 2px with the control radius less 2px. The\n *   picked key sinks and carries the check; a sold-out chip fades under its hairline on a key that lies flat. The color\n *   is the product's and fills only the chip.\n * - Motion: nothing glides. A key switches in place in three steps on the instant tier (100ms), as the Switch's detent\n *   does; reduced motion zeroes the tier, so it switches at once.\n * - Forced colors drops shadows and fills, so the well and each key get real borders, the picked key a 2px one, a lit lamp\n *   is a system highlight and an unlit one an outline.\n */\nimport type { CSSProperties } from \"react\";\nimport { CheckIcon } from \"../icons\";\nimport type { OptionPickerBodyProps } from \"../option-picker\";\n\n/* The detent: a key changes in three steps on the instant tier, never a glide. */\nconst STEPS = \"steps(3, end)\";\nconst detent: CSSProperties = {\n  transitionProperty: \"transform, box-shadow, color, background-color\",\n  transitionDuration: \"var(--litt-dur-instant)\",\n  transitionTimingFunction: `${STEPS}, var(--litt-ease-out), var(--litt-ease-out), var(--litt-ease-out)`,\n};\n\n/* A key standing, a key down (latched, or held), and a key lying flat (sold out). */\nconst up = \"bg-litt-block shadow-(--litt-key-lift)\";\nconst down = \"bg-litt-block shadow-(--litt-block-press) [transform:translateY(var(--litt-press-depth))]\";\nconst held = \"active:shadow-(--litt-block-press) active:[transform:translateY(var(--litt-press-depth))]\";\nconst flat = \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\";\n\nconst lamp = \"size-1.5 shrink-0 rounded-litt-pill forced-colors:border\";\nconst lampLit = \" bg-litt-fg forced-colors:bg-[Highlight]\";\nconst lampOff = \" shadow-[inset_0_0_0_1px_var(--litt-line-control)] forced-colors:bg-transparent\";\n\n/** Device: a bank of latching keys with engraved legends and lamps, the picked one down; a sold-out one lies flat. */\nexport function OptionPickerDeviceBody({ group, values, value, swatches, onPick, labelOf, inkOf }: OptionPickerBodyProps) {\n  if (swatches) {\n    return (\n      <div data-body=\"device\" className=\"flex flex-wrap gap-2 pb-0.5 pointer-coarse:gap-3\">\n        {values.map((v) => {\n          const on = v.value === value;\n          const out = v.available === false;\n          const ink = inkOf(v);\n          return (\n            <label key={v.value} className=\"litt-target relative block cursor-pointer [-webkit-tap-highlight-color:transparent]\">\n              <input type=\"radio\" name={group} value={v.value} checked={on} onChange={() => onPick(v.value)} className=\"peer sr-only\" />\n              <span className=\"sr-only\">\n                {labelOf(v)}\n                {out && \", sold out\"}\n              </span>\n              <span\n                aria-hidden=\"true\"\n                className={\n                  \"relative grid size-7 place-items-center rounded-litt-control pointer-coarse:size-9 forced-colors:border \" +\n                  (out ? flat : on ? down : up + \" \" + held) +\n                  \" peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-litt-focus\"\n                }\n                style={detent}\n              >\n                <span\n                  className=\"relative grid size-6 place-items-center overflow-hidden rounded-[calc(var(--litt-radius-control)-2px)] bg-cover bg-center pointer-coarse:size-8\"\n                  style={{\n                    backgroundColor: v.swatch?.color,\n                    backgroundImage: v.swatch?.image ? `url(\"${v.swatch.image}\")` : undefined,\n                    color: ink ?? \"var(--litt-fg)\",\n                  }}\n                >\n                  {out && <span className=\"absolute inset-0 bg-litt-canvas opacity-60\" />}\n                  <span className=\"pointer-events-none absolute inset-0 rounded-[calc(var(--litt-radius-control)-2px)] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\" />\n                  {on && <CheckIcon className=\"relative\" />}\n                </span>\n              </span>\n            </label>\n          );\n        })}\n      </div>\n    );\n  }\n  return (\n    <div\n      data-body=\"device\"\n      className=\"relative grid h-litt-control-md w-full grid-flow-col auto-cols-fr items-start gap-0.5 overflow-hidden rounded-litt-control bg-litt-tray p-0.5 shadow-(--litt-tray-shade) forced-colors:border\"\n    >\n      {values.map((v) => {\n        const on = v.value === value;\n        const out = v.available === false;\n        return (\n          <label\n            key={v.value}\n            className={\n              \"litt-target relative grid h-9.5 min-w-0 cursor-pointer select-none place-items-center rounded-[calc(var(--litt-radius-control)-2px)] px-2 font-litt-title text-litt-caption litt-legend \" +\n              \"[-webkit-tap-highlight-color:transparent] forced-colors:border \" +\n              (out\n                ? flat + \" \" + (on ? \"text-litt-fg-subtle\" : \"text-litt-fg-disabled\")\n                : (on ? down + \" text-litt-fg\" : up + \" \" + held + \" text-litt-fg-subtle hover:text-litt-fg\")) +\n              (on ? \" forced-colors:border-2\" : \"\")\n            }\n            style={detent}\n          >\n            <input type=\"radio\" name={group} value={v.value} checked={on} onChange={() => onPick(v.value)} className=\"peer sr-only\" />\n            {/* One line, the lamp at the key's middle, so it stays clear of a round key's curves at every shape. */}\n            <span className=\"relative z-1 flex min-w-0 max-w-full items-center gap-1.5 leading-none\">\n              <span aria-hidden=\"true\" className={lamp + (on ? (out ? \" bg-litt-fg-subtle forced-colors:bg-[Highlight]\" : lampLit) : lampOff)} />\n              <span className={out ? \"shrink-0\" : \"truncate\"}>{labelOf(v)}</span>\n              {out && (\n                <>\n                  {/* The name stays whole; in a narrow key the words after it give way. */}\n                  <span aria-hidden=\"true\" className=\"min-w-0 truncate text-litt-fg-disabled\">sold out</span>\n                  <span className=\"sr-only\">, sold out</span>\n                </>\n              )}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 z-1 rounded-[calc(var(--litt-radius-control)-2px)] peer-focus-visible:outline-2 peer-focus-visible:-outline-offset-2 peer-focus-visible:outline-litt-focus\"\n            />\n          </label>\n        );\n      })}\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/option-picker.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/option-picker.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/option-picker 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Option picker, the Flat body (proposals/worlds.md, Bodies; material.md, 7): the page, in ink. No tray and no track:\n * each value is its own word in a grey pill (--litt-block is the grey), set apart by space, and the one you chose is an ink\n * pill (--litt-fg, with --litt-canvas words). A sold-out value is an outlined pill (off) with its word struck through and\n * \"sold out\" said beside it in words. A color is a plain circle, and the chosen one is ringed in ink. Nothing glides,\n * nothing casts.\n */\nimport type { OptionPickerBodyProps } from \"../option-picker\";\n\nconst tweens = \"transition-[color,background-color] duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent]\";\n\n/** Flat: grey pills with the pick in ink, or plain circles ringed in ink for colors and images. */\nexport function OptionPickerFlatBody({ group, values, value, swatches, onPick, labelOf }: OptionPickerBodyProps) {\n  return (\n    <div data-body=\"flat\" className={\"flex flex-wrap \" + (swatches ? \"gap-3 p-1 pointer-coarse:gap-4\" : \"gap-2\")}>\n      {values.map((v) => {\n        const on = v.value === value;\n        const out = v.available === false;\n        const radio = (\n          <input type=\"radio\" name={group} value={v.value} checked={on} onChange={() => onPick(v.value)} className=\"peer sr-only\" />\n        );\n        if (swatches) {\n          return (\n            <label key={v.value} className=\"litt-target relative block cursor-pointer [-webkit-tap-highlight-color:transparent]\">\n              {radio}\n              <span className=\"sr-only\">\n                {labelOf(v)}\n                {out && \", sold out\"}\n              </span>\n              <span\n                aria-hidden=\"true\"\n                className=\"relative block size-6 overflow-hidden rounded-litt-pill bg-cover bg-center pointer-coarse:size-8\"\n                style={{\n                  backgroundColor: v.swatch?.color,\n                  backgroundImage: v.swatch?.image ? `url(\"${v.swatch.image}\")` : undefined,\n                }}\n              >\n                {out && <span className=\"absolute inset-0 bg-litt-canvas opacity-60\" />}\n                {/* The product's color needs an edge on any page; this is content, not chrome. */}\n                <span className=\"pointer-events-none absolute inset-0 rounded-litt-pill shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\" />\n                {/* Sold out is struck: one ink line across the circle. */}\n                {out && <span className=\"absolute top-1/2 left-0 h-px w-full -rotate-45 bg-litt-fg\" />}\n              </span>\n              {/* The ring: ink round the chosen circle, off the circle by 2px. */}\n              <span\n                aria-hidden=\"true\"\n                className={\"pointer-events-none absolute -inset-1 rounded-litt-pill border-2 border-litt-fg \" + (on ? \"\" : \"hidden\")}\n              />\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute -inset-2 rounded-litt-pill peer-focus-visible:outline-2 peer-focus-visible:outline-litt-focus\"\n              />\n            </label>\n          );\n        }\n        return (\n          <label\n            key={v.value}\n            className={\n              \"litt-target relative flex h-litt-control-sm cursor-pointer select-none items-center gap-1.5 rounded-litt-pill border px-litt-control-pad-sm text-litt-small font-litt-title \" +\n              tweens +\n              \" \" +\n              (on\n                ? \"border-litt-fg bg-litt-fg text-litt-canvas forced-colors:border-2\"\n                : out\n                  ? \"border-litt-line-control bg-transparent text-litt-fg-subtle\"\n                  : \"border-transparent bg-litt-block text-litt-fg-muted hover:bg-[color-mix(in_oklab,var(--litt-fg)_8%,var(--litt-block))] hover:text-litt-fg active:bg-[color-mix(in_oklab,var(--litt-fg)_12%,var(--litt-block))] forced-colors:border\")\n            }\n          >\n            {radio}\n            <span className={\"litt-trim relative z-1 truncate\" + (out ? \" line-through\" : \"\")}>\n              {labelOf(v)}\n              {out && <span className=\"sr-only\">, sold out</span>}\n            </span>\n            {out && (\n              <span aria-hidden=\"true\" className=\"litt-trim relative z-1 text-litt-caption font-normal\">\n                sold out\n              </span>\n            )}\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute -inset-0.5 rounded-litt-pill peer-focus-visible:outline-2 peer-focus-visible:outline-offset-0 peer-focus-visible:outline-litt-focus\"\n            />\n          </label>\n        );\n      })}\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/option-picker.flat.tsx"
    }
  ],
  "docs": "Import OptionPicker from components/litt/option-picker. Pass the option's name and values; give values a swatch ({ color } or { image }) to show chips. optionsFor from product-card marks which values are sold out among the other picks.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
