{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"switch","type":"registry:ui","title":"Switch","description":"A sliding on/off control that takes effect immediately, built on a native checkbox input","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/switch/Switch.tsx","content":"\"use client\";\n\nimport { forwardRef, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./switch.css\";\n\nexport type SwitchSize = \"sm\" | \"md\" | \"lg\";\n\ninterface SwitchOwnProps {\n\t/**\n\t * Whether the switch is on. Controlled when supplied: pair it with\n\t * `onCheckedChange`, the React counterpart of the Svelte source's\n\t * `bind:checked`.\n\t */\n\tchecked?: boolean;\n\t/** Initial on/off state when uncontrolled (no `checked` prop given). */\n\tdefaultChecked?: boolean;\n\t/** Called with the new value whenever the switch is activated. */\n\tonCheckedChange?: (checked: boolean) => void;\n\t/** Blocks interaction; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Native `required`. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Element id. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name`, read on form submission. */\n\tname?: string;\n\t/** Form value submitted while on. */\n\tvalue?: string;\n\t/**\n\t * Accessible name, rendered as `aria-label`. Typically for a control\n\t * with no visible `children` text; also applies alongside `children`\n\t * that render no text of their own (e.g. an icon), since the two props\n\t * aren't mutually exclusive and there is no way to detect from here\n\t * whether an arbitrary node renders text. Skip this when `children`\n\t * already supplies the visible label text — passing both means\n\t * `aria-label` wins the accessible name and the visible text is\n\t * announced by nothing.\n\t */\n\tlabel?: string;\n\t/** Visible label text, rendered beside the track. */\n\tchildren?: ReactNode;\n\t/** Track/knob size. */\n\tsize?: SwitchSize;\n\t/** Additional CSS classes, merged onto the wrapping `<label>`. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off\n\t * by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport interface SwitchProps\n\textends\n\t\tOmit<ComponentPropsWithoutRef<\"input\">, keyof SwitchOwnProps | \"type\" | \"role\">,\n\t\tSwitchOwnProps {}\n\n/**\n * A native `<input type=\"checkbox\">` with `role=\"switch\"` — the input IS the\n * track, restyled rather than replaced, with the knob drawn as a pseudo-element\n * so the input stays the only focusable, checkable thing.\n *\n * The element reference arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`), pointing at the native `<input>`.\n */\nexport const Switch = forwardRef<HTMLInputElement, SwitchProps>(\n\t(\n\t\t{\n\t\t\tchecked: checkedProp,\n\t\t\tdefaultChecked = false,\n\t\t\tonCheckedChange,\n\t\t\tdisabled = false,\n\t\t\trequired = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tvalue,\n\t\t\tlabel,\n\t\t\tchildren,\n\t\t\tsize = \"md\",\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t\tonChange,\n\t\t\t\"aria-label\": ariaLabel,\n\t\t\t\"aria-describedby\": ariaDescribedby,\n\t\t\t\"aria-invalid\": ariaInvalid,\n\t\t\t...rest\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\tconst isControlled = checkedProp !== undefined;\n\t\tconst [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked);\n\t\tconst checked = isControlled ? checkedProp : uncontrolledChecked;\n\n\t\t// Undefined outside a FormField — every fallback below then uses this\n\t\t// component's own props instead of the context, so the control works\n\t\t// standalone exactly as it does wrapped. Switch has no own `invalid`\n\t\t// prop (a switch takes effect immediately; there is usually nothing to\n\t\t// validate), but it still surfaces aria-invalid when a surrounding\n\t\t// FormField says the field is invalid.\n\t\tconst field = useField();\n\n\t\tconst effectiveId = field?.controlId ?? id;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\tconst effectiveRequired = field?.required ?? required;\n\t\tconst effectiveInvalid = field?.invalid ?? false;\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\t// The native `disabled` attribute already blocks real interaction, but\n\t\t// a synthetic event dispatched straight at the element — as a test\n\t\t// does — walks past that guard, so the handler repeats it.\n\t\tfunction handleChange(event: ChangeEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled) {\n\t\t\t\t// A disabled control must never let its visible state drift\n\t\t\t\t// from the app's own model. A real browser already refuses to\n\t\t\t\t// run the default toggle action on a disabled checkbox, but a\n\t\t\t\t// synthetic event dispatched straight at the element — as a\n\t\t\t\t// test does — can still mutate the DOM property directly, so\n\t\t\t\t// the handler puts it back rather than trusting the guard\n\t\t\t\t// above alone.\n\t\t\t\tevent.currentTarget.checked = checked;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst next = event.currentTarget.checked;\n\t\t\tif (!isControlled) setUncontrolledChecked(next);\n\t\t\tplayCue(next ? \"toggle-on\" : \"toggle-off\");\n\t\t\tonCheckedChange?.(next);\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\tconst wrapperClasses = cn(\n\t\t\t\"ft-switch-wrap inline-flex items-center gap-[10px] text-[13px]\",\n\t\t\teffectiveDisabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\",\n\t\t\tclassName\n\t\t);\n\n\t\treturn (\n\t\t\t<label className={wrapperClasses}>\n\t\t\t\t<input\n\t\t\t\t\t{...rest}\n\t\t\t\t\tref={forwardedRef}\n\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\trole=\"switch\"\n\t\t\t\t\tdata-size={size}\n\t\t\t\t\tclassName=\"ft-switch\"\n\t\t\t\t\tid={effectiveId}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tchecked={checked}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\taria-checked={checked}\n\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : ariaInvalid}\n\t\t\t\t\taria-describedby={\n\t\t\t\t\t\t[field?.describedBy, ariaDescribedby].filter(Boolean).join(\" \") || undefined\n\t\t\t\t\t}\n\t\t\t\t\taria-label={label ?? ariaLabel}\n\t\t\t\t\tonChange={handleChange}\n\t\t\t\t/>\n\t\t\t\t{children}\n\t\t\t</label>\n\t\t);\n\t}\n);\n\nSwitch.displayName = \"Switch\";\n","type":"registry:ui","target":"@ui/fancy/components/switch/Switch.tsx"},{"path":"react/src/components/switch/index.ts","content":"\"use client\";\n\nexport { Switch } from \"./Switch\";\nexport type { SwitchProps, SwitchSize } from \"./Switch\";\n","type":"registry:ui","target":"@ui/fancy/components/switch/index.ts"},{"path":"react/src/components/switch/switch.css","content":"/*\n * The native input IS the track — restyled, not replaced. The knob is a\n * `::after` pseudo-element on it rather than a second DOM node, so the\n * input stays the only focusable, checkable thing (same rule\n * RadioGroupItem's own filled centre follows).\n */\n.ft-switch {\n\t--ft-switch-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\t/* One local INTERNAL alias so the token pair is typed once rather than four\n\t   times. Declared here, on the control itself, so a value inherited from an\n\t   ancestor never reaches it — it is deliberately not a theming knob, and the\n\t   README says so. Retiming a Switch is done through --ft-duration-fast /\n\t   --ft-ease-inout, which this reads and which do inherit.\n\t   150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout.\n\t   `inout` and not `out`: a switch is a reversible state flip, not an\n\t   arrival — the knob travels the same way in both directions. */\n\t--ft-switch-motion: var(--ft-duration-fast, 150ms)\n\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\tappearance: none;\n\tflex: none;\n\tmargin: 0;\n\tborder-radius: 999px;\n\tbackground-color: var(--color-input, rgba(154, 163, 178, 0.3));\n\tposition: relative;\n\tcursor: pointer;\n\tvertical-align: middle;\n\t/* Colour is a state change, not motion (the knob's slide below is the\n\t   only thing here that travels), so it stays outside the reduced-motion\n\t   query — the same rule Toggle's and Input's own colour transitions\n\t   follow. */\n\ttransition: background-color var(--ft-switch-motion);\n}\n\n.ft-switch::after {\n\tcontent: \"\";\n\tposition: absolute;\n\ttop: 3px;\n\tleft: 3px;\n\tborder-radius: 50%;\n\tbackground-color: var(--color-muted-foreground, #9aa3b2);\n\ttransition: background-color var(--ft-switch-motion);\n}\n\n.ft-switch:checked {\n\tbackground-color: var(--ft-switch-accent);\n}\n\n.ft-switch:checked::after {\n\tbackground-color: white;\n}\n\n.ft-switch:disabled {\n\tcursor: not-allowed;\n}\n\n.ft-switch:focus-visible {\n\toutline: none;\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-switch-accent) 25%, transparent);\n}\n\n/*\n * Geometry per size — exact pixel values, not the closest Tailwind scale\n * step, so the three sizes stay proportioned to each other and the\n * knob's travel always lands exactly on\n * track width − knob size − 2 × inset.\n */\n.ft-switch[data-size=\"sm\"] {\n\twidth: 32px;\n\theight: 18px;\n}\n.ft-switch[data-size=\"sm\"]::after {\n\twidth: 12px;\n\theight: 12px;\n}\n.ft-switch[data-size=\"sm\"]:checked::after {\n\ttransform: translateX(14px);\n}\n\n.ft-switch[data-size=\"md\"] {\n\twidth: 40px;\n\theight: 22px;\n}\n.ft-switch[data-size=\"md\"]::after {\n\twidth: 16px;\n\theight: 16px;\n}\n.ft-switch[data-size=\"md\"]:checked::after {\n\ttransform: translateX(18px);\n}\n\n.ft-switch[data-size=\"lg\"] {\n\twidth: 48px;\n\theight: 26px;\n}\n.ft-switch[data-size=\"lg\"]::after {\n\twidth: 20px;\n\theight: 20px;\n}\n.ft-switch[data-size=\"lg\"]:checked::after {\n\ttransform: translateX(22px);\n}\n\n/*\n * The knob's slide is the only motion here — colour changes above are\n * exempt from reduced-motion the same way Toggle's and Input's own\n * hover/pressed colour transitions are, but a moving element needs the\n * opt-out. Under reduced motion the knob still lands in the right place,\n * it just snaps instead of sliding.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-switch::after {\n\t\ttransition:\n\t\t\tbackground-color var(--ft-switch-motion),\n\t\t\ttransform var(--ft-switch-motion);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/switch/switch.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/switch?framework=react"}}