Back to components

Expandable Slider

A composable slider that stays expanded on mobile and grows from an icon button on desktop hover, focus, or drag.

formwidth-animation
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

// The surface is one pill: collapsed it is an icon button, expanded it is a
// wider button that holds the track. Pill radii nest cleanly, so the trigger
// fills the surface's content box instead of carrying its own box model.
//
// The glyph, rail, and handle keep the YouTube player's volume control sizing;
// the box around them is tighter and the track is longer. The rim alone no
// longer clears the glyph, so the track carries a small leading inset to hold
// YouTube's 8px gap. The tail is wider than the lead only because our surface
// ends in a rounded cap that a player bar does not have.
const SURFACE_HEIGHT = 36;
const SURFACE_BORDER = 1;
const TRIGGER_SIZE = SURFACE_HEIGHT - SURFACE_BORDER * 2;
const ICON_SIZE = 24;
const DEFAULT_TRACK_WIDTH = 72;
const RAIL_HEIGHT = 3;
const THUMB_SIZE = 12;
const TRACK_INSET = 4;
const TRACK_END_INSET = 12;
const PAGE_STEP_MULTIPLIER = 10;
// Leaving the surface should not snap it shut: a grace period forgives a
// pointer that clips the rounded cap on its way to the thumb. 300ms is the
// hover-intent convention (Radix's close delay) and keeps the whole exit —
// grace plus the 200ms collapse — inside half a second, so a control this
// frequently used never reads as stuck open.
const DEFAULT_COLLAPSE_DELAY = 300;

type ExpandableSliderContextValue = {
  value: number;
  min: number;
  max: number;
  step: number;
  /** Position of the value in [0, 1]. */
  ratio: number;
  disabled: boolean;
  expanded: boolean;
  dragging: boolean;
  label: string;
  valueText?: string;
  trackWidth: number;
  /** Attached by the rail; every measurement is taken from it. */
  railRef: React.RefObject<HTMLDivElement | null>;
  commitValue: (next: number) => void;
  valueFromPointer: (clientX: number) => number | undefined;
  stepBy: (steps: number) => void;
  setDragging: (dragging: boolean) => void;
};

const ExpandableSliderContext =
  React.createContext<ExpandableSliderContextValue | null>(null);

function useExpandableSliderContext(component: string) {
  const context = React.useContext(ExpandableSliderContext);

  if (!context) {
    throw new Error(`${component} must be rendered inside <ExpandableSlider>.`);
  }

  return context;
}

export type ExpandableSliderProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange"
> & {
  /** Controlled value. */
  value?: number;
  /** Initial value for uncontrolled usage. */
  defaultValue?: number;
  /** Called after the slider requests a value change. */
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Accessible name applied to the track. */
  label: string;
  /** Spoken value, e.g. `(value) => `${value}%``. */
  formatValueText?: (value: number) => string;
  /** Controlled desktop expansion; mobile viewports stay visually expanded. */
  expanded?: boolean;
  /** Called after the expanded state changes. */
  onExpandedChange?: (expanded: boolean) => void;
  /** Grace period in ms before collapsing once the pointer leaves; 0 collapses immediately. */
  collapseDelay?: number;
  /** Expanded track width in pixels. */
  trackWidth?: number;
  disabled?: boolean;
};

/**
 * Owns the value, the expansion state, and the pill surface. Compose the
 * revealed content from `ExpandableSliderTrigger` and `ExpandableSliderTrack`;
 * the track grows toward whichever side it is written on. Below the `sm`
 * breakpoint, the surface stays expanded so touch users can reach the slider
 * without a hover-only reveal step.
 */
export function ExpandableSlider({
  value,
  defaultValue = 0,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  label,
  formatValueText,
  expanded,
  onExpandedChange,
  collapseDelay = DEFAULT_COLLAPSE_DELAY,
  trackWidth = DEFAULT_TRACK_WIDTH,
  disabled = false,
  className,
  style,
  children,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ExpandableSliderProps) {
  const [internalValue, setInternalValue] = React.useState(() =>
    clampToStep(defaultValue, min, max, step),
  );
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [dragging, setDragging] = React.useState(false);
  const railRef = React.useRef<HTMLDivElement | null>(null);
  // The countdown is stored as the moment the pointer left rather than as a
  // running timer, so a `collapseDelay` change re-arms it against the time
  // already elapsed instead of waiting out the old value.
  const [collapsePendingSince, setCollapsePendingSince] = React.useState<
    number | null
  >(null);

  React.useEffect(() => {
    if (collapsePendingSince === null) return;

    const remaining = Math.max(
      0,
      collapseDelay - (Date.now() - collapsePendingSince),
    );
    const timeout = setTimeout(() => {
      setCollapsePendingSince(null);
      setHovered(false);
    }, remaining);

    return () => clearTimeout(timeout);
  }, [collapseDelay, collapsePendingSince]);

  const controlled = value !== undefined;
  const currentValue = clampToStep(
    controlled ? value : internalValue,
    min,
    max,
    step,
  );
  const isExpanded =
    expanded ?? (disabled ? false : hovered || focused || dragging);

  const previousExpandedRef = React.useRef(isExpanded);

  React.useEffect(() => {
    if (previousExpandedRef.current === isExpanded) return;

    previousExpandedRef.current = isExpanded;
    onExpandedChange?.(isExpanded);
  }, [isExpanded, onExpandedChange]);

  const commitValue = React.useCallback(
    (next: number) => {
      if (!controlled) {
        setInternalValue(next);
      }

      if (next !== currentValue) {
        onValueChange?.(next);
      }
    },
    [controlled, currentValue, onValueChange],
  );

  const valueFromPointer = React.useCallback(
    (clientX: number) => {
      const rail = railRef.current;

      if (!rail) return undefined;

      const rect = rail.getBoundingClientRect();
      const usable = rect.width - THUMB_SIZE;

      if (usable <= 0) return undefined;

      const rawRatio = clamp(
        (clientX - rect.left - THUMB_SIZE / 2) / usable,
        0,
        1,
      );
      // Pointer coordinates are always physical; in an RTL context the track
      // runs right-to-left, so the ratio has to be mirrored to stay on the
      // same side of the rail as the fill.
      const ratio = isRtl(rail) ? 1 - rawRatio : rawRatio;

      return clampToStep(min + ratio * (max - min), min, max, step);
    },
    [max, min, step],
  );

  const stepBy = React.useCallback(
    (steps: number) => {
      commitValue(clampToStep(currentValue + steps * step, min, max, step));
    },
    [commitValue, currentValue, max, min, step],
  );

  const context = React.useMemo<ExpandableSliderContextValue>(
    () => ({
      value: currentValue,
      min,
      max,
      step,
      ratio: max > min ? (currentValue - min) / (max - min) : 0,
      disabled,
      expanded: isExpanded,
      dragging,
      label,
      valueText: formatValueText?.(currentValue),
      trackWidth,
      railRef,
      commitValue,
      valueFromPointer,
      stepBy,
      setDragging,
    }),
    [
      commitValue,
      currentValue,
      disabled,
      dragging,
      formatValueText,
      isExpanded,
      label,
      max,
      min,
      step,
      stepBy,
      trackWidth,
      valueFromPointer,
    ],
  );

  return (
    <ExpandableSliderContext.Provider value={context}>
      <div
        data-slot="expandable-slider"
        data-expanded={isExpanded}
        data-disabled={disabled || undefined}
        className={cn(
          // Mobile is expanded from first paint; desktop starts as a ghost icon
          // button and materialises on interaction. Keeping this responsive
          // branch in CSS avoids a matchMedia hydration flash.
          "group/expandable-slider inline-flex w-fit items-center rounded-full border border-border bg-background bg-clip-padding shadow-xs",
          "transition-[background-color,border-color,box-shadow] duration-200 ease-[cubic-bezier(0,0,0.2,1)] motion-reduce:transition-none",
          "sm:border-transparent sm:bg-transparent sm:shadow-none sm:data-[expanded=true]:border-border sm:data-[expanded=true]:bg-background sm:data-[expanded=true]:shadow-xs",
          "dark:border-input dark:bg-input/30 sm:dark:border-transparent sm:dark:bg-transparent sm:dark:data-[expanded=true]:border-input sm:dark:data-[expanded=true]:bg-input/30",
          // Focus lives on the children, but the ring belongs to the surface:
          // the panel clips its own content, so a child ring would be cut off.
          "has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
          disabled && "opacity-50",
          className,
        )}
        style={{ height: SURFACE_HEIGHT, ...style }}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          setCollapsePendingSince(null);
          setHovered(true);
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);

          if (collapseDelay <= 0) {
            setCollapsePendingSince(null);
            setHovered(false);
            return;
          }

          setCollapsePendingSince(Date.now());
        }}
        onFocus={(event) => {
          onFocus?.(event);
          setFocused(true);
        }}
        onBlur={(event) => {
          onBlur?.(event);

          if (!event.currentTarget.contains(event.relatedTarget)) {
            setFocused(false);
          }
        }}
        {...props}
      >
        {children}
      </div>
    </ExpandableSliderContext.Provider>
  );
}

export type ExpandableSliderTriggerProps = React.ComponentProps<"button">;

/** The always-visible control. Pass any icon as `children`. */
export function ExpandableSliderTrigger({
  className,
  style,
  disabled,
  type = "button",
  ...props
}: ExpandableSliderTriggerProps) {
  const context = useExpandableSliderContext("ExpandableSliderTrigger");
  const isDisabled = disabled ?? context.disabled;

  return (
    <button
      type={type}
      disabled={isDisabled}
      data-slot="expandable-slider-trigger"
      className={cn(
        "inline-flex shrink-0 items-center justify-center rounded-full text-foreground outline-none transition-colors",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--expandable-slider-icon-size)",
        isDisabled ? "cursor-not-allowed" : "hover:bg-muted",
        className,
      )}
      style={
        {
          width: TRIGGER_SIZE,
          height: TRIGGER_SIZE,
          "--expandable-slider-icon-size": `${ICON_SIZE}px`,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    />
  );
}

export type ExpandableSliderTrackProps = React.ComponentProps<"div"> & {
  /** Edge the track is anchored to while the panel grows. Use `end` when the track is written before the trigger. */
  align?: "start" | "end";
};

/**
 * The revealed, full-height slider region — the pointer target is the whole
 * region, not the hairline rail, so a 3px bar never has to be hit. Defaults to
 * a rail with a range and a thumb; pass `children` to replace them.
 */
export function ExpandableSliderTrack({
  align = "start",
  className,
  style,
  children,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  ...props
}: ExpandableSliderTrackProps) {
  const context = useExpandableSliderContext("ExpandableSliderTrack");
  const {
    commitValue,
    disabled,
    dragging,
    label,
    max,
    min,
    setDragging,
    stepBy,
    trackWidth,
    value,
    valueFromPointer,
    valueText,
  } = context;

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging) return;

    setDragging(false);

    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
  };

  const leadInset = align === "start" ? TRACK_INSET : TRACK_END_INSET;
  const tailInset = align === "start" ? TRACK_END_INSET : TRACK_INSET;

  return (
    <div
      data-slot="expandable-slider-panel"
      className={cn(
        "flex h-full w-(--expandable-slider-panel-width) items-center overflow-hidden transition-[width] duration-200 ease-[cubic-bezier(0,0,0.2,1)] will-change-[width] motion-reduce:transition-none",
        "sm:w-0 sm:group-data-[expanded=true]/expandable-slider:w-(--expandable-slider-panel-width)",
        align === "start" ? "justify-start" : "justify-end",
      )}
      style={
        {
          "--expandable-slider-panel-width": `${trackWidth + leadInset + tailInset}px`,
        } as React.CSSProperties
      }
    >
      <div
        role="slider"
        tabIndex={disabled ? -1 : 0}
        aria-label={label}
        aria-orientation="horizontal"
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={valueText}
        aria-disabled={disabled || undefined}
        data-slot="expandable-slider-track"
        className={cn(
          "flex h-full shrink-0 touch-none items-center outline-none",
          disabled ? "cursor-not-allowed" : "cursor-pointer",
          className,
        )}
        style={{
          width: trackWidth,
          marginLeft: leadInset,
          marginRight: tailInset,
          ...style,
        }}
        onPointerDown={(event) => {
          onPointerDown?.(event);

          if (event.defaultPrevented || disabled || event.button !== 0) return;

          // Keep the press from selecting surrounding text mid-drag.
          event.preventDefault();
          event.currentTarget.focus();
          event.currentTarget.setPointerCapture?.(event.pointerId);
          setDragging(true);

          const next = valueFromPointer(event.clientX);

          if (next !== undefined) {
            commitValue(next);
          }
        }}
        onPointerMove={(event) => {
          onPointerMove?.(event);

          if (event.defaultPrevented || !dragging) return;

          const next = valueFromPointer(event.clientX);

          if (next !== undefined) {
            commitValue(next);
          }
        }}
        onPointerUp={(event) => {
          onPointerUp?.(event);
          endDrag(event);
        }}
        onPointerCancel={(event) => {
          onPointerCancel?.(event);
          endDrag(event);
        }}
        onKeyDown={(event) => {
          onKeyDown?.(event);

          if (event.defaultPrevented || disabled) return;

          const steps = KEY_STEPS[event.key];

          if (steps !== undefined) {
            event.preventDefault();
            // Horizontal arrows follow the writing direction (ArrowLeft
            // increases in RTL); the vertical pair and PageUp/Down keep their
            // absolute meaning.
            const directional =
              HORIZONTAL_KEYS.includes(event.key) && isRtl(event.currentTarget)
                ? -steps
                : steps;

            stepBy(directional);
            return;
          }

          if (event.key === "Home") {
            event.preventDefault();
            commitValue(min);
          } else if (event.key === "End") {
            event.preventDefault();
            commitValue(max);
          }
        }}
        {...props}
      >
        {children ?? (
          <ExpandableSliderRail>
            <ExpandableSliderRange />
            <ExpandableSliderThumb />
          </ExpandableSliderRail>
        )}
      </div>
    </div>
  );
}

export type ExpandableSliderRailProps = React.ComponentProps<"div">;

/**
 * The hairline the value is measured against. It publishes the resolved
 * geometry as CSS variables so a replacement range or thumb can read it.
 */
export function ExpandableSliderRail({
  className,
  style,
  ...props
}: ExpandableSliderRailProps) {
  const { railRef, ratio, trackWidth } = useExpandableSliderContext(
    "ExpandableSliderRail",
  );
  const thumbOffset = ratio * Math.max(0, trackWidth - THUMB_SIZE);

  return (
    <div
      ref={railRef}
      aria-hidden="true"
      data-slot="expandable-slider-rail"
      className={cn("relative w-full rounded-full bg-primary/20", className)}
      style={
        {
          height: RAIL_HEIGHT,
          "--expandable-slider-thumb-size": `${THUMB_SIZE}px`,
          "--expandable-slider-thumb-offset": `${thumbOffset}px`,
          // The fill stops at the thumb's centre, not its leading edge.
          "--expandable-slider-fill": `${thumbOffset + THUMB_SIZE / 2}px`,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    />
  );
}

export type ExpandableSliderRangeProps = React.ComponentProps<"div">;

/** The filled portion of the rail. */
export function ExpandableSliderRange({
  className,
  ...props
}: ExpandableSliderRangeProps) {
  return (
    <div
      data-slot="expandable-slider-range"
      className={cn(
        "absolute inset-y-0 start-0 w-(--expandable-slider-fill) rounded-full bg-primary",
        className,
      )}
      {...props}
    />
  );
}

export type ExpandableSliderThumbProps = React.ComponentProps<"div">;

/** The handle. On desktop, it collapses with the surface to avoid clipping. */
export function ExpandableSliderThumb({
  className,
  ...props
}: ExpandableSliderThumbProps) {
  return (
    <div
      data-slot="expandable-slider-thumb"
      className={cn(
        "absolute top-1/2 start-(--expandable-slider-thumb-offset) size-(--expandable-slider-thumb-size) -translate-y-1/2 scale-100 rounded-full bg-primary shadow-sm",
        "transition-transform duration-200 ease-[cubic-bezier(0,0,0.2,1)] motion-reduce:transition-none",
        "sm:group-data-[expanded=false]/expandable-slider:scale-0",
        className,
      )}
      {...props}
    />
  );
}

const KEY_STEPS: Record<string, number | undefined> = {
  ArrowRight: 1,
  ArrowUp: 1,
  ArrowLeft: -1,
  ArrowDown: -1,
  PageUp: PAGE_STEP_MULTIPLIER,
  PageDown: -PAGE_STEP_MULTIPLIER,
};

const HORIZONTAL_KEYS = ["ArrowLeft", "ArrowRight"];

/** Reads the resolved writing direction, so `dir` anywhere up the tree counts. */
function isRtl(element: Element) {
  return window.getComputedStyle(element).direction === "rtl";
}

function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value));
}

function clampToStep(value: number, min: number, max: number, step: number) {
  const clamped = clamp(value, min, max);

  if (!(step > 0)) {
    return clamped;
  }

  const snapped = min + Math.round((clamped - min) / step) * step;

  return roundToStepPrecision(clamp(snapped, min, max), step);
}

function roundToStepPrecision(value: number, step: number) {
  const decimals = getDecimalCount(step);

  return decimals === 0 ? Math.round(value) : Number(value.toFixed(decimals));
}

function getDecimalCount(step: number) {
  const text = String(step);

  if (text.includes("e") || text.includes("E")) {
    return 0;
  }

  const separatorIndex = text.indexOf(".");

  return separatorIndex === -1 ? 0 : text.length - separatorIndex - 1;
}

Installation