{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lock-toggle",
  "title": "Lock toggle",
  "description": "A Material Design 3 switch in the theme's tokens whose knob carries a lock when on and an open lock when off, with M3's spring slide, growing knob, rotating icon and pointer halo.",
  "dependencies": [
    "motion",
    "lucide-react",
    "class-variance-authority"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/lock-toggle/lock-toggle.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotionConfig } from \"motion/react\";\nimport { cva } from \"class-variance-authority\";\nimport { Lock, Unlock } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * An instant on/off switch in Material Design 3's physics, drawn in the\n * theme's tokens, whose knob carries a lock when on and an open lock when off.\n *\n * A switch says \"this is on, and flipping it is immediate\". Use it where the\n * flip *is* the save: a protection going on or off, a setting that applies at\n * once. Where the answer is part of a form submitted later, use a checkbox.\n *\n * `tone` names the colour of the *on* state. `stop` (the default) is for a\n * switch where turning it off is the risky direction, such as disarming a\n * protection: the live, about-to-be-disarmed thing is what wears the colour.\n * `go` is a healthy on, `lead` a neutral one.\n *\n * Under the track is a real checkbox input with `role=\"switch\"`, hidden and\n * driven by the track, so Space, a wrapping or pointing `<label>` and screen\n * readers all treat it as a switch. Controlled when `checked` is given: the\n * knob follows the caller, so a switch that asks for confirmation before\n * turning off stays visibly on until the answer is in.\n *\n * The knob grows when pressed and again when it carries an icon, the icon\n * rotates in, and a halo of the same tone swells under the pointer. Reduced\n * motion drops every transition and the switch simply changes state.\n */\nexport type LockToggleTone = \"lead\" | \"go\" | \"stop\";\nexport type LockToggleSize = \"sm\" | \"md\";\n\nconst trackVariants = cva(\n  \"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-rule bg-paper-sunk transition-colors duration-300 peer-disabled:cursor-not-allowed\",\n  {\n    variants: {\n      tone: {\n        lead: \"peer-checked:border-lead peer-checked:bg-lead\",\n        go: \"peer-checked:border-go peer-checked:bg-go\",\n        stop: \"peer-checked:border-stop peer-checked:bg-stop\",\n      },\n      size: {\n        md: \"h-8 w-[52px]\",\n        sm: \"h-6 w-10\",\n      },\n    },\n    defaultVariants: { tone: \"stop\", size: \"md\" },\n  },\n);\n\n/** The knob's icon colour and the halo, per tone, painted over the track. */\nconst TONES: Record<LockToggleTone, { onIcon: string; halo: string }> = {\n  lead: { onIcon: \"text-lead\", halo: \"bg-lead\" },\n  go: { onIcon: \"text-go\", halo: \"bg-go\" },\n  stop: { onIcon: \"text-stop\", halo: \"bg-stop\" },\n};\n\n/** M3's spring, as a CSS easing so the slide overshoots and settles. */\nconst SPRING_EASE = \"ease-[cubic-bezier(0.175,0.885,0.32,1.275)]\";\n\n/**\n * A synthesised click through the Web Audio API. Off by default: a settings\n * screen that makes a noise is a surprise in a shared office, and a setting's\n * state should never depend on the sound card.\n */\nfunction playHaptic(type: \"heavy\" | \"light\" | \"none\") {\n  if (type === \"none\" || typeof window === \"undefined\") return;\n  try {\n    const AudioContextClass =\n      window.AudioContext ??\n      (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n    if (!AudioContextClass) return;\n    const ctx = new AudioContextClass();\n    const oscillator = ctx.createOscillator();\n    const gain = ctx.createGain();\n    oscillator.connect(gain);\n    gain.connect(ctx.destination);\n    const now = ctx.currentTime;\n    if (type === \"heavy\") {\n      oscillator.type = \"triangle\";\n      oscillator.frequency.setValueAtTime(180, now);\n      oscillator.frequency.exponentialRampToValueAtTime(40, now + 0.15);\n      gain.gain.setValueAtTime(0.4, now);\n      gain.gain.exponentialRampToValueAtTime(0.01, now + 0.12);\n      oscillator.start(now);\n      oscillator.stop(now + 0.15);\n    } else {\n      oscillator.type = \"sine\";\n      oscillator.frequency.setValueAtTime(800, now);\n      gain.gain.setValueAtTime(0.15, now);\n      gain.gain.exponentialRampToValueAtTime(0.01, now + 0.08);\n      oscillator.start(now);\n      oscillator.stop(now + 0.08);\n    }\n    // One context per click, closed when the sound ends: browsers cap how many\n    // can exist, so leaving them open would silence a much-flipped switch.\n    oscillator.onended = () => void ctx.close();\n  } catch {\n    // No sound is fine; the switch still works.\n  }\n}\n\nexport interface LockToggleProps\n  extends Omit<\n    React.InputHTMLAttributes<HTMLInputElement>,\n    \"size\" | \"type\" | \"checked\" | \"defaultChecked\" | \"onChange\"\n  > {\n  ref?: React.Ref<HTMLInputElement>;\n  /** Controlled state. Leave undefined for an uncontrolled switch. */\n  checked?: boolean;\n  defaultChecked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  /** Colour of the on state. `stop` when turning it off is the risky direction. Default `stop`. */\n  tone?: LockToggleTone;\n  size?: LockToggleSize;\n  /** Show an icon in the knob. Default true. */\n  showIcons?: boolean;\n  /** Icon in the knob when on. Default a closed lock. */\n  checkedIcon?: React.ReactNode;\n  /** Icon in the knob when off. Default an open lock. */\n  uncheckedIcon?: React.ReactNode;\n  /** A synthesised click on toggle. Default \"none\". */\n  haptic?: \"heavy\" | \"light\" | \"none\";\n  /** Classes for the track. */\n  className?: string;\n}\n\nexport function LockToggle({\n  ref,\n  className,\n  size = \"md\",\n  tone = \"stop\",\n  checked,\n  defaultChecked,\n  onCheckedChange,\n  showIcons = true,\n  checkedIcon,\n  uncheckedIcon,\n  haptic = \"none\",\n  disabled,\n  style,\n  role = \"switch\",\n  ...props\n}: LockToggleProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const [uncontrolledChecked, setUncontrolledChecked] = React.useState(defaultChecked ?? false);\n  const isChecked = checked ?? uncontrolledChecked;\n  const [isPressed, setIsPressed] = React.useState(false);\n  const [isHovered, setIsHovered] = React.useState(false);\n\n  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const next = event.target.checked;\n    playHaptic(haptic);\n    if (checked === undefined) setUncontrolledChecked(next);\n    onCheckedChange?.(next);\n  };\n\n  const isSmall = size === \"sm\";\n  const toneClasses = TONES[tone];\n  const iconSize = isSmall ? \"size-2.5\" : \"size-3\";\n  const still = reduceMotion ? \"transition-none\" : undefined;\n\n  const knobSize = isPressed\n    ? isSmall\n      ? \"size-5 -ml-[2px]\"\n      : \"size-7 -ml-[2px]\"\n    : isChecked || (showIcons && !isSmall)\n      ? isSmall\n        ? \"size-4\"\n        : \"size-6\"\n      : isSmall\n        ? \"size-3 ml-[2px]\"\n        : \"size-4 ml-[2px]\";\n\n  return (\n    <label\n      className={cn(\n        \"group relative inline-flex min-h-12 min-w-12 items-center justify-center\",\n        disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      style={style}\n      onPointerDown={() => !disabled && setIsPressed(true)}\n      onPointerUp={() => setIsPressed(false)}\n      onPointerLeave={() => {\n        setIsPressed(false);\n        setIsHovered(false);\n      }}\n      onPointerEnter={() => !disabled && setIsHovered(true)}\n    >\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        role={role}\n        className=\"peer sr-only\"\n        checked={isChecked}\n        onChange={handleChange}\n        disabled={disabled}\n        {...props}\n      />\n\n      {/* Track. The focus ring is drawn here: the input is sr-only. */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          trackVariants({ tone, size }),\n          \"peer-focus-visible:ring-2 peer-focus-visible:ring-lead/30 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-card\",\n          still,\n          className,\n        )}\n      >\n        {/* Knob carrier: slides the knob and halo together. */}\n        <span\n          className={cn(\n            \"pointer-events-none relative block h-full w-full transition-all duration-300\",\n            SPRING_EASE,\n            isChecked ? (isSmall ? \"translate-x-[16px]\" : \"translate-x-[20px]\") : \"translate-x-0\",\n            still,\n          )}\n        >\n          {/* Knob */}\n          <span\n            className={cn(\n              \"absolute top-1/2 left-[2px] flex -translate-y-1/2 items-center justify-center rounded-full bg-card transition-all duration-300\",\n              isChecked ? toneClasses.onIcon : \"text-faint\",\n              knobSize,\n              still,\n            )}\n          >\n            {showIcons && (\n              <span className=\"relative flex h-full w-full items-center justify-center overflow-hidden\">\n                <span\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center transition-all duration-300\",\n                    isChecked ? \"scale-100 rotate-0 opacity-100\" : \"scale-50 -rotate-45 opacity-0\",\n                    still,\n                  )}\n                >\n                  {checkedIcon ?? <Lock className={iconSize} strokeWidth={2.5} />}\n                </span>\n                <span\n                  className={cn(\n                    \"absolute inset-0 flex items-center justify-center text-faint transition-all duration-300\",\n                    !isChecked ? \"scale-100 rotate-0 opacity-100\" : \"scale-50 rotate-45 opacity-0\",\n                    still,\n                  )}\n                >\n                  {uncheckedIcon ?? <Unlock className={iconSize} strokeWidth={2.5} />}\n                </span>\n              </span>\n            )}\n          </span>\n\n          {/* Halo: the same tone at 5% on hover and 10% while pressed. */}\n          <span\n            className={cn(\n              \"pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full transition-all duration-200\",\n              isSmall ? \"size-8\" : \"size-10\",\n              isChecked ? toneClasses.halo : \"bg-faint\",\n              isPressed\n                ? \"scale-100 opacity-10\"\n                : isHovered\n                  ? \"scale-100 opacity-5\"\n                  : \"scale-50 opacity-0\",\n              isSmall\n                ? isChecked\n                  ? \"left-[10px]\"\n                  : \"left-[8px]\"\n                : isChecked || showIcons\n                  ? \"left-[14px]\"\n                  : \"left-[10px]\",\n              still,\n            )}\n          />\n        </span>\n      </span>\n    </label>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "kind": "primitive",
    "whenToUse": "Use where flipping it is the save: a protection or setting that applies at once, especially one where turning it off is the risky direction (the default stop tone). Control it with `checked` to hold it on until a confirmation lands. Not for answers in a form submitted later; use a checkbox.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/material-design-3-switch.tsx; used in src/app/settings/data-handling-rules/rules-panel.tsx"
    },
    "credits": [
      {
        "name": "Material Design 3 Switch by Hossain Jahed (EaseMize UI), copied as-is from 21st.dev and restyled with bash-ui tokens. 21st.dev lists its licence as unknown",
        "url": "https://21st.dev/@jahed/components/material-design-3-switch",
        "license": "unknown"
      },
      {
        "name": "Material Design 3: Switch (design spec)",
        "url": "https://m3.material.io/components/switch"
      }
    ]
  },
  "type": "registry:ui"
}
