# iOS Volume Slider

> iOS-style vertical volume slider — drag past the top and the bar stretches, past the bottom it squashes, with sigmoid rubber-band resistance and a spring back.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/ios-volume-slider).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/ios-volume-slider
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/ios-volume-slider-light-befee72696079491.png
- Registry item: https://uicapsule.com/r/ios-volume-slider.json
- Categories: controls, skeuomorphism
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/ios-volume-slider.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@radix-ui/react-slider`, `motion`

## Files

### `/ios-volume-slider.tsx`

```tsx
import { useRef, useState } from "react";
import * as Slider from "@radix-ui/react-slider";
import { animate, motion, useMotionValue, useMotionValueEvent, useTransform } from "motion/react";

/** Height of the track in px. Must stay in sync with the `h-[200px]` class below. */
const TRACK_HEIGHT = 200;
/** How far the bar may rubber-band past either end of the track, in px. */
const OVERSCROLL = 50;

/** Sigmoid falloff: maps unbounded overshoot into (-max, max). */
const decay = (value: number, max: number) => {
  const entry = value / max;
  const sigmoid = 2 / (1 + Math.exp(-entry)) - 1;
  const exit = sigmoid * max;

  return exit;
};

export const Volume = () => {
  const [value, setValue] = useState([0]);
  const [position, setPosition] = useState<"top" | "middle" | "bottom">("middle");

  const clientY = useMotionValue(0);
  const y = useMotionValue(1);

  const ref = useRef<HTMLDivElement>(null);

  useMotionValueEvent(clientY, "change", (latestValue) => {
    if (!ref.current) {
      return;
    }

    const overflow = latestValue - ref.current.getBoundingClientRect().top;

    if (overflow < 0) {
      y.jump(decay(overflow, OVERSCROLL));
      setPosition("bottom");
    } else if (overflow > TRACK_HEIGHT) {
      y.jump(decay(overflow - TRACK_HEIGHT, OVERSCROLL));
      setPosition("top");
    } else {
      y.jump(1);
      setPosition("middle");
    }
  });

  // Negative `y` stretches the bar past the track, positive `y` squashes it.
  const scaleY = useTransform(() => (TRACK_HEIGHT - y.get()) / TRACK_HEIGHT);
  const yReadout = useTransform(() => Math.floor(y.get()));

  return (
    <div className="flex h-96 flex-col items-center justify-center gap-12">
      <Slider.Root
        ref={ref}
        className="relative flex h-[200px] w-[60px] touch-none flex-col items-center select-none"
        value={value}
        onValueChange={setValue}
        max={100}
        step={1}
        orientation="vertical"
        asChild
      >
        <motion.div
          style={{
            scaleY,
            // Both overscroll directions pivot on the track's bottom edge, which is
            // the bar's fixed physical anchor.
            transformOrigin: position === "middle" ? "center" : "bottom",
          }}
          onPointerMove={(events) => {
            if (events.buttons > 0) {
              clientY.set(events.clientY);
            }
          }}
          onLostPointerCapture={() => {
            animate(y, 1, { bounce: 0.5, type: "spring" });
          }}
        >
          <Slider.Track className="relative w-full grow overflow-hidden rounded-xl bg-neutral-800">
            <Slider.Range className="absolute w-full bg-white" />
          </Slider.Track>
        </motion.div>
      </Slider.Root>

      <div className="flex w-96 flex-col items-center gap-1">
        <span className="text-neutral-300">value: {value[0]}</span>
        <span className="text-neutral-300">
          y: <motion.span>{yReadout}</motion.span>
        </span>
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { Volume } from "./ios-volume-slider";

const Preview = () => (
  <main className="flex h-dvh items-center justify-center bg-neutral-900">
    <Volume />
  </main>
);

export default Preview;
```
