Bevel
Browse the docs

DIP switches

A bank of tiny numbered slide switches, ON at the top, as on a circuit board. Every flag is visible at once and the bank reads as one value. For feature flags, permission bits and config words.

Feature flags

Flip a workspace's flags; the bank is also the config value you ship. One is locked by the organisation.

Feature flags: acme-staging

Enforce SSO is locked on by your organisation.

3 of 8 onFLAGS=0x00b

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add DIP switches. This brings in the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/dip-switch
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add @base-ui/react cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Copy dip-switch.tsx into components/ui.

    components/dip-switch.tsx
    "use client"
    
    import * as React from "react"
    import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
    import { cn } from "cn"
    import { useBevelFeedback } from "@/lib/feedback"
    
    type Bit = { value: string; label: string; id: string }
    
    type DipSwitchContextValue = {
      bits: Bit[]
      on: string[]
      disabled: boolean
      register: (bit: Bit) => () => void
      set: (value: string, checked: boolean) => void
    }
    
    const DipSwitchContext = React.createContext<DipSwitchContextValue | null>(null)
    
    function useDipSwitch() {
      const context = React.useContext(DipSwitchContext)
      if (!context) throw new Error("DipSwitch parts must be inside <DipSwitch>")
      return context
    }
    
    type DipSwitchProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
      /** The switches that are on, by value. */
      value?: string[]
      defaultValue?: string[]
      onValueChange?: (value: string[]) => void
      /** Locks every switch. Say why with aria-describedby. */
      disabled?: boolean
      /** Housing colour. */
      variant?: "red" | "blue" | "black"
    }
    
    /**
     * A bank of DIP switches: tiny numbered slide switches for flags and config
     * bits, ON at the top. List them as DipSwitchToggle children inside
     * DipSwitchBank; switch 1 is the lowest bit. Give it an aria-label.
     */
    function DipSwitch({
      className,
      value: valueProp,
      defaultValue = [],
      onValueChange,
      disabled = false,
      variant = "red",
      children,
      ...props
    }: DipSwitchProps) {
      const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
      const on = valueProp ?? uncontrolled
      const [bits, setBits] = React.useState<Bit[]>([])
    
      const register = React.useCallback((bit: Bit) => {
        // Switches keep the order they mounted in, which is their order on the bank.
        setBits((all) =>
          all.some((b) => b.value === bit.value) ? all.map((b) => (b.value === bit.value ? bit : b)) : [...all, bit]
        )
        return () => setBits((all) => all.filter((b) => b.value !== bit.value))
      }, [])
    
      const set = React.useCallback(
        (value: string, checked: boolean) => {
          const next = checked ? [...on.filter((v) => v !== value), value] : on.filter((v) => v !== value)
          if (valueProp === undefined) setUncontrolled(next)
          onValueChange?.(next)
        },
        [on, valueProp, onValueChange]
      )
    
      const context = React.useMemo(() => ({ bits, on, disabled, register, set }), [bits, on, disabled, register, set])
      return (
        <DipSwitchContext value={context}>
          <div
            role="group"
            data-slot="dip-switch"
            data-variant={variant}
            data-disabled={disabled ? "" : undefined}
            className={cn("inline-flex flex-col gap-4", className)}
            {...props}
          >
            {children}
          </div>
        </DipSwitchContext>
      )
    }
    
    /** The plastic housing the switches sit in, with ON printed above them. */
    function DipSwitchBank({ className, children, ...props }: React.ComponentProps<"div">) {
      const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
        // Arrow keys move along the bank; Space flips the switch in focus.
        const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
        if (!step) return
        const switches = [...event.currentTarget.querySelectorAll<HTMLElement>('[role="switch"]:not([aria-disabled="true"])')]
        const i = switches.indexOf(document.activeElement as HTMLElement)
        if (i < 0) return
        switches[Math.min(switches.length - 1, Math.max(0, i + step))]?.focus()
        event.preventDefault()
      }
      return (
        <div
          data-slot="dip-switch-bank"
          onKeyDown={onKeyDown}
          className={cn(
            "bevel-plastic relative inline-flex w-fit flex-col gap-1 rounded-(--bevel-radius-xs) px-2 pt-1.5 pb-1 text-(--dip-switch-print) in-data-disabled:opacity-60",
            className
          )}
          {...props}
        >
          <span
            aria-hidden
            className="flex items-center gap-1 text-(length:--dip-switch-print-size) leading-none font-bold tracking-(--bevel-tracking-print)"
          >
            <span className="dip-switch-arrow" />
            ON
          </span>
          <div className="flex gap-(--dip-switch-gap)">{children}</div>
        </div>
      )
    }
    
    type DipSwitchToggleProps = Omit<SwitchPrimitive.Root.Props, "checked" | "defaultChecked" | "onCheckedChange" | "value" | "children"> & {
      value: string
      /** What the switch controls; it's named by its number and this. */
      children: string
    }
    
    /** One switch. Its number is its position on the bank; its label shows in DipSwitchLegend. */
    function DipSwitchToggle({ className, value, children, disabled: disabledProp, ...props }: DipSwitchToggleProps) {
      const { bits, on, disabled, register, set } = useDipSwitch()
      const feedback = useBevelFeedback()
      const id = React.useId()
      React.useEffect(() => register({ value, label: children, id }), [value, children, id, register])
      const index = bits.findIndex((b) => b.value === value)
      const number = index + 1
      const checked = on.includes(value)
      return (
        <span data-slot="dip-switch-position" className="flex flex-col items-center gap-0.5">
          <SwitchPrimitive.Root
            id={id}
            data-slot="dip-switch-toggle"
            checked={checked}
            disabled={disabled || disabledProp}
            aria-label={number > 0 ? `${number}: ${children}` : children}
            onCheckedChange={(next) => {
              feedback.toggle()
              set(value, next)
            }}
            className={cn(
              "bevel-recessed relative flex h-(--dip-switch-slot-height) w-(--dip-switch-slot-width) cursor-pointer justify-center rounded-(--bevel-radius-xs) py-0.5",
              "outline-none focus-visible:ring-2 focus-visible:ring-(--dip-switch-focus) focus-visible:ring-offset-1 focus-visible:ring-offset-(--dip-switch-body) data-disabled:cursor-not-allowed",
              className
            )}
            {...props}
          >
            <SwitchPrimitive.Thumb
              data-slot="dip-switch-actuator"
              className={cn(
                "bevel-raised block h-(--dip-switch-actuator-height) w-full rounded-(--bevel-radius-xs)",
                "translate-y-(--dip-switch-throw) data-checked:translate-y-0",
                "transition-[translate] duration-(--bevel-duration-press) ease-(--bevel-ease-out) motion-reduce:transition-none"
              )}
            />
          </SwitchPrimitive.Root>
          <span aria-hidden className="text-(length:--dip-switch-print-size) leading-none font-bold tabular-nums">
            {number > 0 ? number : ""}
          </span>
        </span>
      )
    }
    
    /** The key printed beside the bank: each switch's number, what it does, and whether it's on. */
    function DipSwitchLegend({ className, ...props }: React.ComponentProps<"ol">) {
      const { bits, on } = useDipSwitch()
      return (
        <ol
          aria-hidden
          data-slot="dip-switch-legend"
          className={cn("flex flex-col gap-1 text-sm", className)}
          {...props}
        >
          {bits.map((bit, i) => {
            const checked = on.includes(bit.value)
            return (
              <li key={bit.value} data-checked={checked ? "" : undefined} className="flex items-baseline gap-2">
                <span className="w-4 text-right font-mono text-xs text-muted-foreground tabular-nums">{i + 1}</span>
                <span className="min-w-0 flex-1 truncate">{bit.label}</span>
                <span className="font-mono text-xs font-semibold text-muted-foreground in-data-checked:text-foreground">
                  {checked ? "ON" : "OFF"}
                </span>
              </li>
            )
          })}
        </ol>
      )
    }
    
    type DipSwitchReadoutProps = React.ComponentProps<"span"> & {
      /** How to show the bits: binary, hex or decimal. */
      format?: "binary" | "hex" | "decimal"
    }
    
    /** The bank as a number. Switch 1 is the lowest bit; binary reads highest first, as written. */
    function DipSwitchReadout({ className, format = "hex", ...props }: DipSwitchReadoutProps) {
      const { bits, on } = useDipSwitch()
      const n = bits.reduce((sum, bit, i) => (on.includes(bit.value) ? sum + 2 ** i : sum), 0)
      const binary = bits
        .map((bit) => (on.includes(bit.value) ? "1" : "0"))
        .reverse()
        .join("")
      const text =
        format === "binary"
          ? `0b${binary}`
          : format === "decimal"
            ? String(n)
            : `0x${n.toString(16).toUpperCase().padStart(Math.max(1, Math.ceil(bits.length / 4)), "0")}`
      return (
        <span
          data-slot="dip-switch-readout"
          className={cn("font-mono text-sm tabular-nums", className)}
          {...props}
        >
          {text}
        </span>
      )
    }
    
    export { DipSwitch, DipSwitchBank, DipSwitchToggle, DipSwitchLegend, DipSwitchReadout }
    export type { DipSwitchProps, DipSwitchToggleProps, DipSwitchReadoutProps }
  4. Copy dip-switch.css into your styles.

    styles/dip-switch.css
    /*
     * DIP switch: a bank of tiny slide switches in a moulded plastic housing,
     * ON printed at the top, numbers below. Builds on bevel.css (plastic,
     * recessed, raised, motion).
     */
    
    :root {
      --dip-switch-red: oklch(0.5 0.17 27);
      --dip-switch-red-edge: oklch(0.34 0.12 27);
      --dip-switch-blue: oklch(0.45 0.13 258);
      --dip-switch-blue-edge: oklch(0.3 0.09 258);
      --dip-switch-black: oklch(0.26 0.01 260);
      --dip-switch-black-edge: oklch(0.16 0.01 260);
      --dip-switch-slot: oklch(0.18 0.01 260);
      --dip-switch-actuator: oklch(0.96 0.005 90); /* white slider */
      --dip-switch-actuator-edge: oklch(0.72 0.01 90);
      --dip-switch-print: oklch(0.97 0.01 90); /* white silkscreen */
      --dip-switch-focus: oklch(0.97 0.01 90);
      --dip-switch-print-size: 0.625rem;
      --dip-switch-slot-width: 1.25rem;
      --dip-switch-slot-height: 2.75rem;
      --dip-switch-actuator-height: 1.125rem;
      --dip-switch-throw: 1.375rem; /* slot height - actuator - padding */
      --dip-switch-gap: 0.5rem;
    }
    
    [data-slot="dip-switch"] {
      --dip-switch-body: var(--dip-switch-red);
      --dip-switch-body-edge: var(--dip-switch-red-edge);
    }
    [data-slot="dip-switch"][data-variant="blue"] {
      --dip-switch-body: var(--dip-switch-blue);
      --dip-switch-body-edge: var(--dip-switch-blue-edge);
    }
    [data-slot="dip-switch"][data-variant="black"] {
      --dip-switch-body: var(--dip-switch-black);
      --dip-switch-body-edge: var(--dip-switch-black-edge);
    }
    [data-slot="dip-switch-bank"] {
      --bevel-material: var(--dip-switch-body);
      --bevel-material-edge: var(--dip-switch-body-edge);
      --bevel-material-recess: var(--dip-switch-slot);
    }
    [data-slot="dip-switch-actuator"] {
      --bevel-material: var(--dip-switch-actuator);
      --bevel-material-edge: var(--dip-switch-actuator-edge);
    }
    
    /* The printed arrow beside ON, pointing the way the sliders go. */
    @utility dip-switch-arrow {
      width: 0;
      height: 0;
      border-inline: 0.1875rem solid transparent;
      border-bottom: 0.3125rem solid currentColor;
    }
    
    @media (forced-colors: active) {
      [data-slot="dip-switch-toggle"] {
        border: 1px solid CanvasText;
      }
      [data-slot="dip-switch-actuator"] {
        background: ButtonText;
      }
    }
  5. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./dip-switch.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<DipSwitch aria-label="Feature flags" value={on} onValueChange={setOn}>
  <DipSwitchBank>
    <DipSwitchToggle value="streaming">Streamed replies</DipSwitchToggle>
    <DipSwitchToggle value="memory">Long-term memory</DipSwitchToggle>
  </DipSwitchBank>
  <DipSwitchLegend />
  <DipSwitchReadout format="hex" />
</DipSwitch>

Parts

PartWhat it does
DipSwitchdiv (group)value (the values that are on), defaultValue, onValueChange, disabled, variant red, blue or black. Give it an aria-label.
DipSwitchBankdivThe housing, with ON printed above. Left and right arrow keys move along it.
DipSwitchToggleSwitchvalue; its text is its label. Numbered by position, from 1, and named “3: Web search tool”. Space flips it; disabled locks one switch.
DipSwitchLegendol (decorative)Each switch's number, label and ON or OFF, printed beside the bank. Hidden from screen readers, which read the switches.
DipSwitchReadoutspanThe bank as a number: format hex (0x0B), binary (0b00001011) or decimal. Switch 1 is the lowest bit.