# Slider Volume Vertical — React Slider Component

> A tall pill you drag up and down like a phone volume control; the fill follows on a spring, the pill stretches when you pull past either end, and M mutes.

- Category: [Sliders](https://pixelpapa.com/components/sliders.md)
- Tags: slider, volume, vertical, audio, mixer
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/sliders/slider-volume-vertical

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/slider-volume-vertical.json"
```

The file is written to `components/pixelpapa/slider-volume-vertical.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Mic, Music, Volume, Volume1, Volume2, VolumeX } from "lucide-react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type VolumeSliderProps = {
  value?: number;
  defaultValue?: number;
  onChange?: (value: number) => void;
  /** Replaces the speaker icon that reflects the level. */
  icon?: ReactNode;
  label: string;
  className?: string;
};

export function VolumeSlider({ value, defaultValue = 60, onChange, icon, label, className }: VolumeSliderProps) {
  const [inner, setInner] = useState(defaultValue);
  const [dragging, setDragging] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const beforeMute = useRef(defaultValue || 60);
  const reduce = useReducedMotion();
  const level = Math.round(value ?? inner);

  const target = useMotionValue(level);
  const smooth = useSpring(target, { stiffness: 400, damping: 34 });
  const height = useTransform(reduce ? target : smooth, (v) => `${v}%`);
  // Rubber band: how far past the ends the pointer is pulling, as a scale.
  const pull = useMotionValue(0);
  const stretch = useSpring(pull, { stiffness: 500, damping: 22 });
  const scaleY = useTransform(stretch, (v) => 1 + Math.abs(v) * 0.07);
  const scaleX = useTransform(stretch, (v) => 1 - Math.abs(v) * 0.04);
  const originY = useTransform(stretch, (v) => (v > 0 ? 1 : 0));

  useEffect(() => {
    target.set(level);
  }, [level, target]);

  function set(next: number) {
    const clamped = Math.min(100, Math.max(0, Math.round(next)));
    if (clamped === level) return;
    if (clamped > 0) beforeMute.current = clamped;
    if (value === undefined) setInner(clamped);
    onChange?.(clamped);
  }
  const toggleMute = () => set(level === 0 ? beforeMute.current : 0);

  function follow(e: PointerEvent<HTMLDivElement>) {
    const rect = ref.current?.getBoundingClientRect();
    if (!rect) return;
    const p = 1 - (e.clientY - rect.top) / rect.height;
    set(p * 100);
    const over = p > 1 ? p - 1 : p < 0 ? p : 0;
    pull.set(reduce ? 0 : Math.max(-1, Math.min(1, over * 2.5)));
  }

  function release() {
    setDragging(false);
    pull.set(0);
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = {
      ArrowUp: level + 5,
      ArrowRight: level + 5,
      ArrowDown: level - 5,
      ArrowLeft: level - 5,
      PageUp: level + 20,
      PageDown: level - 20,
      Home: 0,
      End: 100,
    };
    if (e.key in moves) {
      e.preventDefault();
      set(moves[e.key]);
    } else if (e.key.toLowerCase() === "m") toggleMute();
  }

  const Speaker = level === 0 ? VolumeX : level < 34 ? Volume : level < 67 ? Volume1 : Volume2;

  return (
    <div className={cn("flex flex-col items-center gap-3", className)}>
      <motion.div
        ref={ref}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-orientation="vertical"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={level}
        aria-valuetext={level === 0 ? "Muted" : `${level}%`}
        style={{ scaleY, scaleX, originY }}
        onKeyDown={onKeyDown}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId);
          setDragging(true);
          follow(e);
        }}
        onPointerMove={(e) => dragging && follow(e)}
        onPointerUp={release}
        onPointerCancel={release}
        className="relative h-48 w-16 cursor-ns-resize touch-none overflow-hidden rounded-[22px] bg-muted outline-offset-4 select-none"
      >
        <motion.div style={{ height }} className="absolute inset-x-0 bottom-0 bg-primary" />
        <span
          className={cn(
            "absolute inset-x-0 top-3 text-center text-sm font-semibold tabular-nums transition-colors",
            level > 82 ? "text-primary-foreground" : "text-foreground",
          )}
        >
          {level}
        </span>
        <span
          className={cn(
            "absolute inset-x-0 bottom-3 flex justify-center transition-colors [&>svg]:size-5",
            level > 14 ? "text-primary-foreground" : "text-muted-foreground",
          )}
        >
          {icon ?? <Speaker />}
        </span>
      </motion.div>
      <button
        type="button"
        aria-pressed={level === 0}
        aria-label={level === 0 ? `Unmute ${label}` : `Mute ${label}`}
        onClick={toggleMute}
        className={cn(
          "h-7 rounded-full px-3 text-xs font-medium transition-colors",
          level === 0 ? "bg-destructive/15 text-destructive" : "bg-muted text-muted-foreground hover:text-foreground",
        )}
      >
        {level === 0 ? "Muted" : "Mute"}
      </button>
    </div>
  );
}

const channels = [
  { name: "Master", start: 72, icon: undefined },
  { name: "Music", start: 45, icon: <Music /> },
  { name: "Voice", start: 88, icon: <Mic /> },
  { name: "Effects", start: 0, icon: undefined },
];

export default function SliderVolumeVerticalDemo() {
  return (
    <div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
      <div className="flex items-baseline justify-between gap-8">
        <h3 className="text-lg font-semibold tracking-tight text-card-foreground">Mixer</h3>
        <p className="text-xs text-muted-foreground">Drag a channel · press M to mute</p>
      </div>
      <div className="mt-5 flex gap-5">
        {channels.map((c) => (
          <div key={c.name} className="flex flex-col items-center gap-2">
            <VolumeSlider label={c.name} defaultValue={c.start} icon={c.icon} />
            <span className="text-xs font-medium text-card-foreground">{c.name}</span>
          </div>
        ))}
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Slider Volume Vertical" component from PixelPapa UI to this project.

What it is: A tall pill you drag up and down like a phone volume control; the fill follows on a spring, the pill stretches when you pull past either end, and M mutes.
Category: Sliders. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/slider-volume-vertical.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/slider-volume-vertical.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/slider-volume-vertical" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More sliders

- [Slider Circular Dial](https://pixelpapa.com/components/sliders/slider-circular-dial.md) (Pro): A thermostat-style dial: drag the knob around a 270° arc and the gradient track, knob and reading follow on a spring; presets sweep it to a new value.
- [Slider Range Dual](https://pixelpapa.com/components/sliders/slider-range-dual.md) (Pro): A two-thumb range slider under a histogram whose bars light up inside the selection; thumbs keep a minimum gap and the nearest one follows a track press.
- [Slider Ruler Ticks](https://pixelpapa.com/components/sliders/slider-ruler-ticks.md) (Pro): A ruler you drag or flick under a fixed needle; it coasts with inertia, snaps to the nearest tick and shows the reading large in the centre.
- [Slider Spring Thumb](https://pixelpapa.com/components/sliders/slider-spring-thumb.md): A slider whose value bubble trails the thumb on a loose spring and leans into the direction of travel, then settles upright.
- [Slider Stepped Labels](https://pixelpapa.com/components/sliders/slider-stepped-labels.md): A discrete slider with labelled stops: the thumb follows your finger freely, then springs onto the nearest stop while its label lights up.
