{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"slider","type":"registry:ui","title":"Slider","description":"Restyled native range input with a purple-to-cyan gradient fill that tracks the current value and a glowing thumb","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/slider/Slider.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./slider.css\";\n\ninterface SliderOwnProps {\n\t/** Current value; seeds internal state and resyncs whenever the prop changes. */\n\tvalue?: number;\n\t/** Called with the new value on every input event. */\n\tonValueChange?: (value: number) => void;\n\t/** Lower bound. */\n\tmin?: number;\n\t/** Upper bound. */\n\tmax?: number;\n\t/** Increment size, including fractional steps. */\n\tstep?: number;\n\t/** Blocks dragging and keyboard interaction. Overridden by a surrounding FormField. */\n\tdisabled?: 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/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Shows the current value in a bubble that tracks the thumb. */\n\tshowValue?: boolean;\n\t/** Shows `min` and `max` as end labels below the track. */\n\tshowBounds?: boolean;\n\t/** Additional CSS classes, applied to the outer wrapper. */\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 SliderProps\n\textends\n\t\tOmit<\n\t\t\tComponentPropsWithoutRef<\"input\">,\n\t\t\tkeyof SliderOwnProps | \"type\" | \"role\" | \"defaultValue\"\n\t\t>,\n\t\tSliderOwnProps {}\n\nexport const Slider = forwardRef<HTMLInputElement, SliderProps>(\n\t(\n\t\t{\n\t\t\tvalue: valueProp = 0,\n\t\t\tonValueChange,\n\t\t\tmin = 0,\n\t\t\tmax = 100,\n\t\t\tstep = 1,\n\t\t\tdisabled = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tlabel,\n\t\t\tshowValue = false,\n\t\t\tshowBounds = false,\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// An `<input>` is a void element: a stray `children` must not reach it.\n\t\t\tchildren: _children,\n\t\t\t...rest\n\t\t},\n\t\tref\n\t) => {\n\t\t// `value` mirrors the Svelte side's `value = $bindable(0)`: the prop\n\t\t// seeds internal state, the component keeps writing that state itself\n\t\t// on every input event, and a caller that re-passes a NEW prop value\n\t\t// resyncs it — the render-phase adjust below, same shape as Input's.\n\t\tconst [value, setValue] = useState(valueProp);\n\t\tconst [seededFrom, setSeededFrom] = useState(valueProp);\n\t\tif (valueProp !== seededFrom) {\n\t\t\tsetSeededFrom(valueProp);\n\t\t\tsetValue(valueProp);\n\t\t}\n\n\t\t// Undefined outside a FormField — every derived below then falls back to\n\t\t// this component's own props instead of the context, so the control works\n\t\t// standalone exactly as it does wrapped.\n\t\tconst field = useField();\n\n\t\tconst effectiveId = field?.controlId ?? id;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\t// Slider carries no local `invalid` prop of its own — a surrounding\n\t\t// FormField is the only source for it, same as aria-describedby below.\n\t\t// `field.required` has no counterpart here at all: the ARIA slider role\n\t\t// does not support aria-required (a range always carries a value, so\n\t\t// there is no \"empty\" state to require filling in), and any visible\n\t\t// required marker is the FormField's own Label to render, not this input's.\n\t\tconst effectiveInvalid = field?.invalid ?? false;\n\n\t\t// A native range input clamps its own displayed value to [min,max]\n\t\t// automatically, but a `value` prop set out of range (or left there\n\t\t// after `max` shrinks past it at runtime) would otherwise leave\n\t\t// aria-valuenow and the showValue bubble reporting the raw, unclamped\n\t\t// number while the thumb sits wherever the browser actually clamped it —\n\t\t// visible to a sighted user as a bubble that disagrees with the thumb's\n\t\t// own position. Every render below reads `clampedValue`, not `value`, so\n\t\t// this is correct from the very first paint (including SSR) regardless\n\t\t// of when the effect below gets around to correcting the state itself.\n\t\tconst clampedValue = Math.min(max, Math.max(min, value));\n\n\t\t// Also corrects `value` itself, the same way NumberInput clamps on\n\t\t// blur, so a caller's own state does not stay silently out of range\n\t\t// forever. `min`/`max` are the only tracked dependency, matching the\n\t\t// Svelte source's `$effect` which reads `value` through `untrack` —\n\t\t// here that untracked read is `valueRef`, a plain mutable ref kept in\n\t\t// sync with `value` on every render (not `useLiveRef`, whose contract\n\t\t// is read-only: this effect also WRITES the mirror back, below, so a\n\t\t// StrictMode re-invoke sees the already-corrected number instead of\n\t\t// the stale one). The clamp is computed in the effect BODY, never\n\t\t// inside a `setValue` updater: React runs updaters during the next\n\t\t// render pass (twice under StrictMode), so calling `onValueChange`\n\t\t// from one would fire a consumer's callback twice for a single clamp\n\t\t// and update a parent from another component's render phase.\n\t\tconst valueRef = useRef(value);\n\t\tvalueRef.current = value;\n\t\tuseEffect(() => {\n\t\t\tconst current = valueRef.current;\n\t\t\tconst clamped = Math.min(max, Math.max(min, current));\n\t\t\tif (clamped !== current) {\n\t\t\t\t// Written back before the state lands so a StrictMode\n\t\t\t\t// re-invoke of this same effect sees the corrected number and\n\t\t\t\t// stays a no-op.\n\t\t\t\tvalueRef.current = clamped;\n\t\t\t\tsetValue(clamped);\n\t\t\t\tonValueChange?.(clamped);\n\t\t\t}\n\t\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t\t}, [min, max]);\n\n\t\t// The one number the gradient fill and the value bubble both need,\n\t\t// computed once here rather than duplicated as a CSS calc() on each\n\t\t// consumer of it.\n\t\tconst fraction = max > min ? Math.min(1, Math.max(0, (clampedValue - min) / (max - min))) : 0;\n\n\t\t// The single place `value` changes. A native `disabled` input never fires\n\t\t// `input` from a real drag or key press, but a synthetic dispatch walks\n\t\t// straight past that guard the same way a synthetic click does on a\n\t\t// button — so the early return is repeated here rather than trusted to\n\t\t// the attribute alone.\n\t\tfunction handleInput(event: ChangeEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\tconst next = Number(event.currentTarget.value);\n\t\t\tsetValue(next);\n\t\t\tonValueChange?.(next);\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\t// React's `onChange` prop on a controlled range input is wired to the\n\t\t// native `input` event (handleInput above), which fires continuously\n\t\t// while dragging — the wrong moment for a cue that must fire once per\n\t\t// commit. The native `change` event (drag release, or once per\n\t\t// committed keyboard step) has no React prop of its own, so it is\n\t\t// picked up with a plain listener on the node itself, mirroring the\n\t\t// Svelte source's separate `oninput`/`onchange` handlers.\n\t\tconst [inputNode, inputNodeRef] = useElementRef<HTMLInputElement>();\n\t\tconst composedRef = useComposedRefs(ref, inputNodeRef);\n\t\tconst effectiveDisabledRef = useLiveRef(effectiveDisabled);\n\t\tconst playCue = useSoundCue(sound);\n\t\tuseEffect(() => {\n\t\t\tif (!inputNode) return;\n\t\t\tfunction handleChange() {\n\t\t\t\tif (effectiveDisabledRef.current) return;\n\t\t\t\tplayCue(\"tick\");\n\t\t\t}\n\t\t\tinputNode.addEventListener(\"change\", handleChange);\n\t\t\treturn () => inputNode.removeEventListener(\"change\", handleChange);\n\t\t}, [inputNode, effectiveDisabledRef, playCue]);\n\n\t\treturn (\n\t\t\t<div className={cn(\"ft-slider-wrap flex w-full flex-col gap-4\", className)}>\n\t\t\t\t<div className=\"relative\" style={showValue ? { paddingTop: \"22px\" } : undefined}>\n\t\t\t\t\t{showValue && (\n\t\t\t\t\t\t// Decorative: the input's own aria-valuenow already carries this\n\t\t\t\t\t\t// number for assistive tech, so the bubble is hidden from it to\n\t\t\t\t\t\t// avoid announcing the value twice.\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"ft-slider-bubble border-input bg-background pointer-events-none absolute -top-1 -translate-x-1/2 -translate-y-full rounded-md border px-2 py-0.5 font-mono text-[11px] whitespace-nowrap\"\n\t\t\t\t\t\t\tstyle={{ left: `${fraction * 100}%`, color: \"var(--ft-slider-accent-end)\" }}\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{clampedValue}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t\t<input\n\t\t\t\t\t\t{...rest}\n\t\t\t\t\t\tref={composedRef}\n\t\t\t\t\t\ttype=\"range\"\n\t\t\t\t\t\tclassName=\"ft-slider h-1 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\tid={effectiveId}\n\t\t\t\t\t\tname={name}\n\t\t\t\t\t\tmin={min}\n\t\t\t\t\t\tmax={max}\n\t\t\t\t\t\tstep={step}\n\t\t\t\t\t\tvalue={clampedValue}\n\t\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\t\taria-describedby={\n\t\t\t\t\t\t\t[field?.describedBy, ariaDescribedby].filter(Boolean).join(\" \") || undefined\n\t\t\t\t\t\t}\n\t\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : ariaInvalid}\n\t\t\t\t\t\taria-valuemin={min}\n\t\t\t\t\t\taria-valuemax={max}\n\t\t\t\t\t\taria-valuenow={clampedValue}\n\t\t\t\t\t\taria-label={label ?? ariaLabel}\n\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t{ ...rest.style, \"--ft-slider-fill\": `${fraction * 100}%` } as React.CSSProperties\n\t\t\t\t\t\t}\n\t\t\t\t\t\tonChange={handleInput}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{showBounds && (\n\t\t\t\t\t// Decorative echo of min/max; the authoritative values live on the\n\t\t\t\t\t// input's own min/max/aria-valuemin/aria-valuemax attributes.\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"text-muted-foreground flex justify-between text-[11px]\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span>{min}</span>\n\t\t\t\t\t\t<span>{max}</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nSlider.displayName = \"Slider\";\n","type":"registry:ui","target":"@ui/fancy/components/slider/Slider.tsx"},{"path":"react/src/components/slider/index.ts","content":"\"use client\";\n\nexport { Slider } from \"./Slider\";\nexport type { SliderProps } from \"./Slider\";\n","type":"registry:ui","target":"@ui/fancy/components/slider/index.ts"},{"path":"react/src/components/slider/slider.css","content":"/*\n * Declared on the wrapper, not on `.ft-slider` itself, so the value bubble\n * — a sibling `<span>`, not a descendant of the input — can inherit the\n * same two custom properties for its text colour instead of duplicating\n * the values. Custom properties inherit down the whole subtree (including\n * into the input's own vendor thumb/track pseudo-elements below), just\n * not sideways between siblings, which is what forced this up a level.\n */\n.ft-slider-wrap {\n\t--ft-slider-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/*\n\t * The fill's cool end has no semantic token either — declared the same\n\t * shape as the accent above, just a second stop for the purple-to-cyan\n\t * gradient the mockup shows tracking the current value.\n\t * Precomputed: #42cfff = oklch(0.8001 0.1345 225.49). Its darker\n\t * light-mode partner is derived the same way the accent pair's is —\n\t * lightness and chroma scaled by the accent pair's own light:dark\n\t * ratios (≈0.899 and ≈0.970), hue shifted by the same ≈-1.53° — giving\n\t * oklch(0.7196 0.1305 223.96). Light mode takes the darker end.\n\t */\n\t--ft-slider-accent-end: light-dark(oklch(0.7196 0.1305 223.96), oklch(0.8001 0.1345 225.49));\n}\n\n/*\n * A native range input has no cross-browser \"filled portion\" of its own.\n * WebKit lets the track's background do double duty: a hard-stop gradient\n * paints the accent up to --ft-slider-fill and the resting track colour\n * after it. Firefox has no equivalent hook on ::-moz-range-track, so its\n * filled portion is drawn as the separate ::-moz-range-progress layer\n * below instead.\n */\n.ft-slider::-webkit-slider-runnable-track {\n\theight: 4px;\n\tborder-radius: 999px;\n\tbackground: linear-gradient(\n\t\tto right,\n\t\tvar(--ft-slider-accent),\n\t\tvar(--ft-slider-accent-end) var(--ft-slider-fill, 0%),\n\t\tvar(--color-input, color-mix(in oklab, currentColor 12%, transparent))\n\t\t\tvar(--ft-slider-fill, 0%)\n\t);\n}\n.ft-slider::-moz-range-track {\n\theight: 4px;\n\tborder-radius: 999px;\n\tbackground: var(--color-input, color-mix(in oklab, currentColor 12%, transparent));\n}\n.ft-slider::-moz-range-progress {\n\theight: 4px;\n\tborder-radius: 999px;\n\tbackground: linear-gradient(to right, var(--ft-slider-accent), var(--ft-slider-accent-end));\n}\n\n/*\n * Vendor pseudo-elements for the thumb cannot be combined into one\n * selector list — a selector list is invalid in its entirety the moment\n * one part of it is unrecognised, so folding ::-webkit-slider-thumb and\n * ::-moz-range-thumb together would make the whole rule drop silently in\n * whichever browser doesn't own the other vendor's part. Two separate\n * rules, restyled identically, is the only way both browsers apply either\n * of them.\n */\n.ft-slider::-webkit-slider-thumb {\n\tappearance: none;\n\twidth: 14px;\n\theight: 14px;\n\tmargin-top: -5px; /* centers a 14px thumb on the 4px track */\n\tborder-radius: 50%;\n\tbackground: var(--color-background, #0f0f13);\n\tborder: 2px solid var(--ft-slider-accent);\n\tbox-shadow: 0 0 0 4px color-mix(in oklab, var(--ft-slider-accent) 25%, transparent);\n}\n.ft-slider::-moz-range-thumb {\n\twidth: 14px;\n\theight: 14px;\n\tborder-radius: 50%;\n\tbackground: var(--color-background, #0f0f13);\n\tborder: 2px solid var(--ft-slider-accent);\n\tbox-shadow: 0 0 0 4px color-mix(in oklab, var(--ft-slider-accent) 25%, transparent);\n}\n\n.ft-slider:disabled::-webkit-slider-thumb {\n\tbox-shadow: none;\n}\n.ft-slider:disabled::-moz-range-thumb {\n\tbox-shadow: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/slider/slider.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/slider?framework=react"}}