Inputs & Formsv0.4.0New
Stretch Pill Slider
A dark pill stretches across the track as you drag and carries its own rolling label.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/stretch-pill-slider.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/stretch-pill-slider/stretch-pill-slider.tsx"use client"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent } from "react"; import { animate, motion, useMotionTemplate, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react"; import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; export interface StretchPillSliderProps extends Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> { /** Controlled value. */ value?: number; /** Initial value when uncontrolled. @default min */ defaultValue?: number; /** Called whenever the value changes, while dragging and from the keyboard. */ onValueChange?: (value: number) => void; /** Called once a change is finished: pointer released or key pressed. */ onValueCommit?: (value: number) => void; /** Lowest value. @default 0 */ min?: number; /** Highest value. @default 100 */ max?: number; /** Distance between values; the pill snaps to each step on release. @default 1 */ step?: number; /** Formats the value for the pill's label and for screen readers, e.g. (v) => `${v} days`. The first number in the result rolls. */ formatValue?: (value: number) => string; /** Label at the right end of the track. @default formatValue(max) */ maxLabel?: string; /** Track height: 40, 48 or 56px (medium grows to 52px on touch screens). @default "md" */ size?: "sm" | "md" | "lg"; /** Pill color: the foreground color or the primary accent. @default "ink" */ tone?: "ink" | "accent"; /** Disables pointer and keyboard input. @default false */ disabled?: boolean; /** Renders a hidden input with this name, so the value is submitted with a form. */ name?: string; } /** Room past the end of the track for the rubber band, in px. */ const SLACK = 8; /** Padding, gap and grip around the label inside the pill, in px. */ const CHROME = 16 + 14 + 3 + 12; /** Half-width of the area around the pill's edge that counts as grabbing the grip, in px. */ const GRIP_ZONE = 28; const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi); const decimals = (n: number) => (String(n).split(".")[1] ?? "").length; /** Resistance past an end: approaches SLACK and never passes it. */ const rubber = (distance: number) => (SLACK * distance) / (distance + 24); const sizes = { sm: "h-10 text-[13px]", md: "h-12 text-sm [@media(pointer:coarse)]:h-[52px]", lg: "h-14 text-[15px]", } as const; const tones = { ink: "bg-foreground text-background", accent: "bg-primary text-primary-foreground", } as const; /** The formatted value with its first number rolling and the rest (the unit) standing still. */ function ValueLabel({ text }: { text: string }) { const match = text.match(/\d+(?:\.\d+)?/); if (!match || match.index === undefined) return <>{text}</>; return ( <> {text.slice(0, match.index)} <RollingDigits value={Number(match[0])} decimals={decimals(Number(match[0]))} format={{ useGrouping: false }} animateOnMount={false} stagger={0} /> {text.slice(match.index + match[0].length)} </> ); } export function StretchPillSlider({ value, defaultValue, onValueChange, onValueCommit, min = 0, max = 100, step = 1, formatValue = (v) => String(v), maxLabel, size = "md", tone = "ink", disabled = false, name, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }: StretchPillSliderProps) { const [internal, setInternal] = useState(() => defaultValue ?? min); const current = clamp(value ?? internal, min, max); const [dragging, setDragging] = useState(false); const reduceMotion = useReducedMotion(); const trackRef = useRef<HTMLDivElement>(null); const sliderRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLSpanElement>(null); const maxLabelRef = useRef<HTMLSpanElement>(null); const controls = useRef<AnimationPlaybackControls | null>(null); const drag = useRef<{ offset: number; value: number } | null>(null); const text = formatValue(current); const endText = maxLabel ?? formatValue(max); const ratio = (v: number) => (v - min) / (max - min || 1); /** 0 at min, 1 at max: where the pill's right edge sits between its own label width and the full track. */ const progress = useMotionValue(ratio(current)); /** Rubber-band stretch past either end, in px. */ const over = useMotionValue(0); /** Narrowest the pill gets: its label plus padding and grip. Estimated until measured. */ const minWidth = useMotionValue(text.length * 8.5 + CHROME); const trackWidth = useMotionValue(0); const maxLabelWidth = useMotionValue(0); // How far the pill's right edge sits from the right end of its layer, as CSS, so the first // paint is right before anything is measured. The layer is the track plus SLACK. const inset = useTransform(() => `calc((100% - ${SLACK}px - ${minWidth.get()}px) * ${1 - progress.get()} + ${SLACK - over.get()}px)`); const clipPath = useMotionTemplate`inset(0 ${inset} 0 0 round 9999px)`; const gripTransform = useMotionTemplate`translateX(calc(-1 * ${inset}))`; // The max label fades out as the pill's edge closes in on it. const maxLabelShown = useTransform(() => { const width = trackWidth.get(); if (!width) return progress.get() >= 1 ? 0 : 1; const edge = minWidth.get() + progress.get() * (width - minWidth.get()) + over.get(); return clamp((width - 16 - maxLabelWidth.get() - edge) / 28, 0, 1); }); const maxLabelFilter = useTransform(() => `blur(${((1 - maxLabelShown.get()) * 4).toFixed(2)}px)`); useLayoutEffect(() => { const track = trackRef.current; const label = labelRef.current; const end = maxLabelRef.current; if (!track || !label || !end) return; const measure = () => { trackWidth.set(track.clientWidth); minWidth.set(label.offsetWidth + CHROME); maxLabelWidth.set(end.offsetWidth); }; measure(); const observer = new ResizeObserver(measure); observer.observe(track); observer.observe(label); observer.observe(end); return () => observer.disconnect(); }, [trackWidth, minWidth, maxLabelWidth]); const settle = useCallback( (to: number) => { controls.current?.stop(); if (reduceMotion) { progress.jump(to); over.jump(0); return; } controls.current = animate(progress, to, spring.snappy); animate(over, 0, spring.snappy); }, [reduceMotion, progress, over], ); // Follow the value when it changes from outside (controlled updates, keyboard), but never fight a drag. const target = ratio(current); useEffect(() => { if (!drag.current) settle(target); }, [target, settle]); useEffect(() => () => controls.current?.stop(), []); const snap = (raw: number) => { const stepped = Math.round((raw - min) / step) * step + min; return clamp(Number(stepped.toFixed(decimals(step))), min, max); }; const commit = (next: number) => { if (next === current) return; if (value === undefined) setInternal(next); onValueChange?.(next); }; /** Where the pill's edge is asked to be, as unclamped progress, for a pointer at clientX. */ function progressAt(clientX: number, offset: number) { const rect = trackRef.current!.getBoundingClientRect(); const span = Math.max(1, rect.width - minWidth.get()); return { raw: (clientX - rect.left + offset - minWidth.get()) / span, span, rect }; } function follow(clientX: number) { if (!drag.current) return; const { raw, span } = progressAt(clientX, drag.current.offset); controls.current?.stop(); progress.set(clamp(raw, 0, 1)); over.set(raw > 1 ? rubber((raw - 1) * span) : raw < 0 ? -rubber(-raw * span) : 0); const next = snap(min + clamp(raw, 0, 1) * (max - min)); if (next !== drag.current.value) { drag.current.value = next; commit(next); } } function onPointerDown(event: PointerEvent<HTMLDivElement>) { if (disabled || event.button !== 0) return; const { raw, span, rect } = progressAt(event.clientX, 0); const edge = rect.left + minWidth.get() + progress.get() * span; const onGrip = Math.abs(event.clientX - edge) <= GRIP_ZONE; event.currentTarget.setPointerCapture(event.pointerId); sliderRef.current?.focus({ preventScroll: true }); drag.current = { offset: onGrip ? edge - event.clientX : 0, value: current }; setDragging(true); if (onGrip) return; // A press away from the grip springs the edge to the pointer; moving then takes over 1:1. const next = snap(min + clamp(raw, 0, 1) * (max - min)); drag.current.value = next; commit(next); controls.current?.stop(); if (reduceMotion) progress.jump(clamp(raw, 0, 1)); else controls.current = animate(progress, clamp(raw, 0, 1), spring.snappy); } function endDrag() { if (!drag.current) return; const final = drag.current.value; drag.current = null; setDragging(false); settle(ratio(final)); onValueCommit?.(final); } function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { const big = Math.max(step, snap(min + (max - min) / 10) - min); const targets: Record<string, number> = { ArrowRight: current + step, ArrowUp: current + step, ArrowLeft: current - step, ArrowDown: current - step, PageUp: current + big, PageDown: current - big, Home: min, End: max, }; if (!(event.key in targets) || disabled) return; event.preventDefault(); const next = snap(targets[event.key]); commit(next); onValueCommit?.(next); } return ( <div ref={trackRef} data-dragging={dragging || undefined} onPointerDown={onPointerDown} onPointerMove={(event) => follow(event.clientX)} onPointerUp={endDrag} onPointerCancel={endDrag} className={cn( "group/slider relative w-full min-w-40 cursor-pointer touch-pan-y rounded-full bg-muted font-medium select-none", sizes[size], disabled && "pointer-events-none opacity-50", className, )} {...props} > <motion.span ref={maxLabelRef} aria-hidden="true" style={{ opacity: maxLabelShown, filter: maxLabelFilter }} className="absolute top-1/2 right-4 -translate-y-1/2 whitespace-nowrap text-muted-foreground tabular-nums motion-reduce:filter-none!" > {endText} </motion.span> {/* The focusable slider. Its shadow and focus ring are filters, so they follow the clipped pill inside. */} <div ref={sliderRef} role="slider" tabIndex={disabled ? -1 : 0} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-valuemin={min} aria-valuemax={max} aria-valuenow={current} aria-valuetext={text} aria-orientation="horizontal" aria-disabled={disabled || undefined} onKeyDown={onKeyDown} style={{ right: -SLACK }} className={cn( "absolute inset-y-0 left-0 outline-none transition-[filter] duration-200", "[--lift:drop-shadow(0_1px_1px_rgb(0_0_0/0.08))] [filter:var(--lift)]", "group-data-dragging/slider:[--lift:drop-shadow(0_6px_10px_rgb(0_0_0/0.22))]", "focus-visible:[filter:drop-shadow(2px_0_0_var(--ring))_drop-shadow(-2px_0_0_var(--ring))_drop-shadow(0_2px_0_var(--ring))_drop-shadow(0_-2px_0_var(--ring))]", )} > <motion.div style={{ clipPath }} className={cn("absolute inset-0", tones[tone])}> <span ref={labelRef} aria-hidden="true" className="absolute top-1/2 left-4 -translate-y-1/2 whitespace-nowrap"> <ValueLabel text={text} /> </span> <motion.span aria-hidden="true" style={{ transform: gripTransform }} className="group/grip pointer-events-none absolute inset-0"> <span className="pointer-events-auto absolute inset-y-0 right-0 flex w-10 cursor-grab items-center justify-end pr-3 group-data-dragging/slider:cursor-grabbing"> <span className="h-4 w-[3px] rounded-full bg-current opacity-55 transition-[transform,opacity] duration-150 group-hover/grip:scale-x-[1.34] group-hover/grip:opacity-80 group-data-dragging/slider:scale-x-[1.34] group-data-dragging/slider:opacity-80" /> </span> </motion.span> </motion.div> </div> {name ? <input type="hidden" name={name} value={current} disabled={disabled} /> : null} </div> ); }
Usage
import { StretchPillSlider } from "@/components/ui/stretch-pill-slider/stretch-pill-slider";
export function RentalDays() {
const [days, setDays] = useState(2);
return (
<StretchPillSlider
aria-label="Rental period"
value={days}
onValueChange={setDays}
min={1}
max={7}
formatValue={(v) => `${v} ${v === 1 ? "day" : "days"}`}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | – | Controlled value. |
defaultValue | number | min | Initial value when uncontrolled. |
onValueChange | (value: number) => void | – | Called whenever the value changes, while dragging and from the keyboard. |
onValueCommit | (value: number) => void | – | Called once a change is finished: pointer released or key pressed. |
min | number | 0 | Lowest value. |
max | number | 100 | Highest value. |
step | number | 1 | Distance between values; the pill snaps to each step on release. |
formatValue | (value: number) => string | – | Formats the value for the pill's label and for screen readers, e.g. (v) => `${v} days`. The first number in the result rolls. |
maxLabel | string | formatValue(max) | Label at the right end of the track. |
size | "sm" | "md" | "lg" | "md" | Track height: 40, 48 or 56px (medium grows to 52px on touch screens). |
tone | "ink" | "accent" | "ink" | Pill color: the foreground color or the primary accent. |
disabled | boolean | false | Disables pointer and keyboard input. |
name | string | – | Renders a hidden input with this name, so the value is submitted with a form. |
Also accepts every native <div> attribute, including ref and className.
- The pill never gets narrower than its own label, so the lowest value still reads in full. The max label fades out as the pill's edge reaches it.
- The first number in formatValue's result rolls with Rolling Digits. The rest, such as the unit, stands still, so pluralize it in formatValue.
- The pill is a clip-path over a full-width layer and the grip is translated with it, so dragging never animates layout. Its shadow and focus ring are drop-shadow filters, which follow the clipped shape.
- Past either end the pill stretches up to 8px with a rubber band and springs back on release.
- Pass name to render a hidden input for forms.
Accessibility
- The pill has role="slider" with aria-valuenow, aria-valuemin, aria-valuemax and an aria-valuetext built from formatValue, such as "3 days".
- Arrow keys move one step, PageUp and PageDown move about a tenth of the range, and Home and End jump to the ends.
- Give it a name with aria-label or aria-labelledby.
- Focus draws a ring around the pill itself, and pressing the track moves focus to the pill so the keyboard picks up where the pointer left off.
- The track uses touch-action: pan-y, so a vertical swipe that starts on the slider still scrolls the page.
- With reduced motion enabled, the pill jumps to each value, digits swap instantly and the max label fades without a blur.