DialKit

A compound for building front-panel control surfaces — chips, segmented toggles, and switches sit inside collapsible labelled panels. Dial is the outer kit; Dial.Panel, Dial.Chips, Dial.Segment, and Dial.Toggle are the parts you compose inside.

Mode
View
Customize
Shape
3
State

Installation

npx shadcn@latest add https://craftbits.dev/r/dialkit.json

Usage

DialKit is a compound — Dial owns the LayoutGroup so segmented controls inside don't collide across instances, Dial.Panel is a disclosable section, and the three control parts each own their value state with controlled + uncontrolled modes.

import { Dial } from "@craft-bits/core";
 
<Dial>
  <Dial.Panel title="Tone">
    <Dial.Chips
      label="Mode"
      options={["wave", "saw", "square"]}
      defaultValue="wave"
    />
    <Dial.Segment
      label="View"
      options={["S", "M", "L"]}
      defaultValue="M"
    />
    <Dial.Toggle label="Mute" defaultChecked={false} />
  </Dial.Panel>
</Dial>

Controlled — pair each part's value (or checked) with its onValueChange (or onCheckedChange):

const [mode, setMode] = useState("wave");
const [mute, setMute] = useState(false);
 
<Dial>
  <Dial.Panel title="Tone">
    <Dial.Chips
      label="Mode"
      options={["wave", "saw", "square"]}
      value={mode}
      onValueChange={setMode}
    />
    <Dial.Toggle
      label="Mute"
      checked={mute}
      onCheckedChange={setMute}
    />
  </Dial.Panel>
</Dial>

Understanding the component

  1. Compound parts. Dial is the outer card + LayoutGroup; Dial.Panel is a disclosable section with a header button; Dial.Chips, Dial.Segment, Dial.Toggle are the labelled control rows. Composing them as children rather than passing a flat controls array keeps the call-site declarative.
  2. Shared LayoutGroup, isolated layoutIds. Dial creates a useId()-scoped LayoutGroup so each Segment's sliding active background animates only within its own kit — multiple panels on the same page never tween into each other.
  3. Independent value state per control. Chips, Segment, Toggle each own their value / checked state (controlled + uncontrolled). There is no parent "values" object — the kit is a layout, not a form.
  4. Panel disclosure. Dial.Panel is a button-triggered region with aria-expanded + aria-controls. The body collapses with SPRINGS.damped (critically-damped, no overshoot) so the height settle stays sharp.
  5. Keyboard model for Chips & Segment. Arrow Left/Right (and Up/Down) cycle selection with wrap-around; Home / End jump to the ends. The active option is the only tab-stop (tabIndex={0}), matching the W3C radio-group pattern.
  6. Toggle as a switch. Dial.Toggle is role="switch" (not a button) so screen readers announce on/off. The knob slides between two pinned positions with SPRINGS.snap — mechanical, not bouncy.

Props

Dial

PropTypeDefaultDescription
classNamestringMerged onto the rendered <div>.
childrenReactNodePanels and labelled rows.
...restHTMLAttributes<HTMLDivElement>Any other <div> prop.

Dial.Panel

PropTypeDefaultDescription
titlestringrequiredHeader label — rendered uppercase + mono.
defaultOpenbooleantrueInitial open state (uncontrolled).
openbooleanControlled open state. Pair with onOpenChange.
onOpenChange(open: boolean) => voidCalled when the header button toggles the panel.
classNamestringMerged onto the rendered <div>.

Dial.Chips

PropTypeDefaultDescription
labelstringrequiredRow label, also the accessible name of the group.
optionsreadonly T[]requiredSelectable chip values.
valueTControlled selection.
defaultValueToptions[0]Uncontrolled starting selection.
onValueChange(value: T) => voidCalled when the user picks a new chip.
renderOption(option: T) => ReactNodeCustom per-chip render.
classNamestringMerged onto the outer row <div>.

Dial.Segment

PropTypeDefaultDescription
labelstringrequiredRow label, also the accessible name of the group.
optionsreadonly T[]requiredSegmented values.
valueTControlled selection.
defaultValueToptions[0]Uncontrolled starting selection.
onValueChange(value: T) => voidCalled on selection change.
formatOption(option: T) => ReactNodeFormat each segment's visible label.
classNamestringMerged onto the outer row <div>.

Dial.Toggle

PropTypeDefaultDescription
labelstringrequiredVisible label — doubles as the accessible name.
checkedbooleanControlled checked state.
defaultCheckedbooleanfalseUncontrolled initial state.
onCheckedChange(checked: boolean) => voidCalled on toggle.
classNamestringMerged onto the rendered <button>.

Accessibility

  • Dial.Panel is a <button> driving aria-expanded and aria-controls. The body is role="region" labelled by the header for landmark navigation.
  • Dial.Chips and Dial.Segment are role="radiogroup" (labelled by their row label) with each option role="radio" + aria-checked. Only the active option is in the tab sequence; arrow keys cycle with wrap-around, Home / End jump to the ends — the canonical W3C radio-group keyboard model.
  • Dial.Toggle is role="switch" with aria-checked and aria-labelledby pointing at its visible label. Screen readers announce "on / off" rather than "pressed / not pressed."
  • Focus is visible via focus-visible: rings keyed to --cb-accent, offset from --cb-bg so they stay legible on every theme surface.
  • The sliding active background in Dial.Segment uses Motion's layoutId and respects prefers-reduced-motion — Motion collapses the layout transition to an instant swap.
  • Color contrast in the default theme: row labels use --cb-fg-muted and selected text on the active segment uses --cb-accent-fg against --cb-accent; both pass WCAG AA.

Credits

  • Extracted from: terminal-dreams (src/components/ui/dialkit/). The original five files (Dial.tsx, DialPanel.tsx, DialChips.tsx, DialSegment.tsx, DialToggle.tsx) were renamed and re-architected as a Radix-style compound. The original Dial was a numeric scrubber slider; in the library, Dial is the container — the slider concern is covered by other primitives in the buttons & inputs section.