{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle","type":"registry:ui","title":"Toggle","description":"Two-state button carrying aria-pressed, for a single on/off option like a formatting mark or a filter","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["actions"],"registryDependencies":["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/toggle/Toggle.tsx","content":"\"use client\";\n\nimport { forwardRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./toggle.css\";\n\nexport type ToggleSize = \"sm\" | \"md\" | \"lg\";\nexport type ToggleVariant = \"ghost\" | \"outline\";\n\ntype BaseProps = {\n\t/** Whether the toggle is currently pressed (active) */\n\tpressed?: boolean;\n\t/** Called with the new pressed state whenever the toggle is activated */\n\tonPressedChange?: (pressed: boolean) => void;\n\t/** Visual size of the control */\n\tsize?: ToggleSize;\n\t/** `\"ghost\"` has no resting border, `\"outline\"` keeps one at rest */\n\tvariant?: ToggleVariant;\n\t/** Accessible name — required when `children` is icon-only */\n\tlabel?: string;\n\t/** Toggle content, typically a single glyph or a short label */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n\t/**\n\t * Form field name. When set, a hidden input named `name` is rendered inside\n\t * the button and submits `value` (default `\"on\"`) while pressed, nothing\n\t * while off or disabled, like a checkbox. The button is `type=\"button\"` and\n\t * never submits.\n\t */\n\tname?: string;\n\t/**\n\t * Value submitted under `name` while pressed. Defaults to `\"on\"`. Keeps the\n\t * native button `value` type, so a number is still accepted.\n\t */\n\tvalue?: ButtonHTMLAttributes<HTMLButtonElement>[\"value\"];\n\t/** `id` of the `<form>` the hidden input belongs to, when not nested in it. */\n\tform?: string;\n};\n\nexport interface ToggleProps\n\textends BaseProps, Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps> {}\n\nconst SIZE_CLASSES: Record<ToggleSize, string> = {\n\tsm: \"size-[30px] rounded-[6px] text-xs\",\n\tmd: \"size-[36px] rounded-[8px] text-sm\",\n\tlg: \"size-[42px] rounded-[10px] text-base\",\n};\n\nexport const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(\n\t(\n\t\t{\n\t\t\tpressed: pressedProp,\n\t\t\tonPressedChange,\n\t\t\tdisabled = false,\n\t\t\tsize = \"md\",\n\t\t\tvariant = \"ghost\",\n\t\t\tlabel,\n\t\t\tchildren,\n\t\t\tclassName,\n\t\t\tonClick,\n\t\t\tsound = false,\n\t\t\tname,\n\t\t\tvalue,\n\t\t\tform,\n\t\t\t...rest\n\t\t},\n\t\tref\n\t) => {\n\t\t// `pressed` mirrors the Svelte side's `pressed = $bindable(false)`: an\n\t\t// initial value the caller can seed, held afterward as the component's\n\t\t// own state and reassigned locally on every toggle regardless of\n\t\t// whether the caller re-passes the prop. `useState`'s lazy initializer\n\t\t// runs once, matching a bindable prop's one-time seed.\n\t\tconst [pressed, setPressed] = useState(pressedProp ?? false);\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\tconst classes = cn(\n\t\t\t// No `transition-colors` here: the colocated CSS below declares a\n\t\t\t// `transition` shorthand on this same element, and it is loaded after\n\t\t\t// Tailwind's utility layer, so the utility would have been silently\n\t\t\t// replaced anyway. The colour channel is re-declared by hand there\n\t\t\t// instead.\n\t\t\t\"ft-toggle inline-flex shrink-0 cursor-pointer items-center justify-center font-medium\",\n\t\t\t\"focus-visible:outline-none\",\n\t\t\t\"disabled:pointer-events-none disabled:opacity-50\",\n\t\t\tSIZE_CLASSES[size],\n\t\t\tvariant === \"outline\" && \"border border-border\",\n\t\t\tpressed\n\t\t\t\t? \"bg-secondary text-secondary-foreground\"\n\t\t\t\t: \"text-muted-foreground hover:bg-accent hover:text-accent-foreground\",\n\t\t\tclassName\n\t\t);\n\n\t\t// The only place `pressed` changes.\n\t\tfunction toggle(event: React.MouseEvent<HTMLButtonElement>) {\n\t\t\tif (disabled) return;\n\t\t\tconst next = !pressed;\n\t\t\tsetPressed(next);\n\t\t\tplayCue(next ? \"toggle-on\" : \"toggle-off\");\n\t\t\tonPressedChange?.(next);\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\treturn (\n\t\t\t<button\n\t\t\t\t{...rest}\n\t\t\t\tref={ref}\n\t\t\t\ttype=\"button\"\n\t\t\t\tclassName={classes}\n\t\t\t\taria-pressed={pressed}\n\t\t\t\taria-label={label ?? rest[\"aria-label\"]}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tonClick={toggle}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t\t{/* Inside the button so the component keeps one root element; a hidden\n\t\t\t\t    input is not interactive content, and it still joins the form. */}\n\t\t\t\t{name && pressed && !disabled ? (\n\t\t\t\t\t<input type=\"hidden\" name={name} value={value ?? \"on\"} form={form} />\n\t\t\t\t) : null}\n\t\t\t</button>\n\t\t);\n\t}\n);\n\nToggle.displayName = \"Toggle\";\n","type":"registry:ui","target":"@ui/fancy/components/toggle/Toggle.tsx"},{"path":"react/src/components/toggle/index.ts","content":"\"use client\";\n\nexport { Toggle } from \"./Toggle\";\nexport type { ToggleProps, ToggleSize, ToggleVariant } from \"./Toggle\";\n","type":"registry:ui","target":"@ui/fancy/components/toggle/index.ts"},{"path":"react/src/components/toggle/toggle.css","content":"/*\n * The brand accent has no semantic token in the app's theme layer, so it is\n * declared locally with a light-dark() fallback — the same shape the AI\n * family components use for --ft-status-error. A consumer theme can still\n * override it by setting --ft-accent higher up the tree.\n */\n.ft-toggle {\n\t--ft-toggle-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 alias so the token pair is typed once rather than six times.\n\t   150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout */\n\t--ft-toggle-motion: var(--ft-duration-fast, 150ms)\n\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t/* The pressed ring is painted by an absolutely-positioned pseudo, which\n\t   needs a containing block. `relative` on an inline-flex button changes\n\t   no layout. */\n\tposition: relative;\n\t/* Replaces the `transition-colors` utility removed from the class string\n\t   above. Colour is a state change, not motion, so it stays outside the\n\t   reduced-motion query. `text-decoration-color`, `fill` and `stroke`\n\t   never change on this control, so the three that do are the faithful\n\t   subset of what the utility covered. */\n\ttransition:\n\t\tcolor var(--ft-toggle-motion),\n\t\tbackground-color var(--ft-toggle-motion),\n\t\tborder-color var(--ft-toggle-motion);\n\t/* Kills the ~300ms tap delay without blocking scroll — the same rule,\n\t   for the same reason, as `.ft-pressable`. */\n\ttouch-action: manipulation;\n}\n\n/* Pressed state gets an inset accent ring on top of the secondary surface,\n   rather than a plain border, so the active look survives on both the\n   ghost and outline variants without fighting their own border.\n   It lives on `::before` rather than on the button itself because the\n   button's own `box-shadow` is its focus ring: a ring that must never\n   animate cannot share a property with a signal that should. Same shadow,\n   same inherited radius, one layer down — the resting look is unchanged. */\n.ft-toggle::before {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: inherit;\n\tpointer-events: none;\n\tbox-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ft-toggle-accent) 50%, transparent);\n\topacity: 0;\n}\n\n.ft-toggle[aria-pressed=\"true\"]::before {\n\topacity: 1;\n}\n\n/* One rule, not two: with the pressed ring on the pseudo, focus while\n   pressed no longer has to spell out both shadows at once — the pseudo\n   paints the inset ring and the host paints the focus ring, on separate\n   layers that cannot overwrite each other. */\n.ft-toggle:focus-visible {\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-toggle-accent) 35%, transparent);\n}\n\n/* Universal fallback — press feedback must survive reduced motion, and a UA\n   that supports neither query still needs some pressed affordance. */\n.ft-toggle:active:not(:disabled) {\n\topacity: var(--ft-toggle-press-opacity, 0.85);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-toggle {\n\t\t/* The individual `scale` property, not `transform: scale()`: this\n\t\t   rule is loaded after Tailwind's utility layer, so a `transform` here\n\t\t   would beat any transform utility a consumer passes through the\n\t\t   public `className` prop — a `rotate-45` would silently vanish, at\n\t\t   rest AND under the press. `scale` composes with the consumer's\n\t\t   `transform` instead of replacing it. */\n\t\tscale: 1;\n\t\ttransition:\n\t\t\tcolor var(--ft-toggle-motion),\n\t\t\tbackground-color var(--ft-toggle-motion),\n\t\t\tborder-color var(--ft-toggle-motion),\n\t\t\tscale var(--ft-toggle-motion);\n\t}\n\n\t.ft-toggle:active:not(:disabled) {\n\t\tscale: var(--ft-toggle-press-scale, 0.97);\n\t\t/* Full motion = scale only; reduced motion = opacity only. Never both. */\n\t\topacity: 1;\n\t}\n\n\t/* A 1px inset ring has no axis to grow along, so only its opacity\n\t   travels — the signal arrives on the same clock as the colour beside\n\t   it instead of a frame ahead of it. */\n\t.ft-toggle::before {\n\t\ttransition: opacity var(--ft-toggle-signal-duration, var(--ft-duration-fast, 150ms))\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/toggle/toggle.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/toggle?framework=react"}}