Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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

PropTypeDefaultDescription
valuenumber–Controlled value.
defaultValuenumberminInitial 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.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Distance 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.
maxLabelstringformatValue(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.
disabledbooleanfalseDisables pointer and keyboard input.
namestring–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.