Web InnoventixFreeCode

Vertical Slider

Original · free

vertical sliders

byWeb InnoventixReact + Tailwind
sldverticalsliders
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/sld-vertical.json
sld-vertical.tsx
"use client";

import {
  useCallback,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

const clamp = (n: number, lo: number, hi: number): number =>
  Math.min(hi, Math.max(lo, n));

type Band = { label: string; freq: string };

const BANDS: Band[] = [
  { label: "Sub", freq: "60 Hz" },
  { label: "Bass", freq: "150 Hz" },
  { label: "Low Mid", freq: "400 Hz" },
  { label: "Mid", freq: "1 kHz" },
  { label: "Hi Mid", freq: "2.4 kHz" },
  { label: "Presence", freq: "6 kHz" },
  { label: "Air", freq: "12 kHz" },
];

type Preset = { name: string; values: number[] };

const FLAT: number[] = [0, 0, 0, 0, 0, 0, 0];

const PRESETS: Preset[] = [
  { name: "Flat", values: FLAT },
  { name: "Bass Boost", values: [8, 6, 3, 0, -1, -1, 0] },
  { name: "Vocal Clarity", values: [-3, -2, 0, 3, 5, 3, 1] },
  { name: "Live Rock", values: [5, 3, -1, 2, 4, 5, 3] },
  { name: "Acoustic", values: [3, 2, 1, 1, 2, 3, 4] },
];

const EQ_MIN = -12;
const EQ_MAX = 12;
const DEFAULT_MASTER = 72;

const eqLabel = (v: number): string => `${v > 0 ? "+" : ""}${v} dB`;
const pctLabel = (v: number): string => `${v}%`;

type FaderProps = {
  label: string;
  freq: string;
  value: number;
  min: number;
  max: number;
  step: number;
  bigStep: number;
  bipolar: boolean;
  accentUp: string;
  accentDown: string;
  format: (v: number) => string;
  onChange: (v: number) => void;
  reduce: boolean;
};

function Fader({
  label,
  freq,
  value,
  min,
  max,
  step,
  bigStep,
  bipolar,
  accentUp,
  accentDown,
  format,
  onChange,
  reduce,
}: FaderProps) {
  const trackRef = useRef<HTMLDivElement | null>(null);
  const draggingRef = useRef(false);
  const [dragging, setDragging] = useState(false);

  const pct = ((value - min) / (max - min)) * 100;
  const centerPct = bipolar ? ((0 - min) / (max - min)) * 100 : 0;

  const commit = useCallback(
    (clientY: number) => {
      const el = trackRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const ratio = clamp(1 - (clientY - rect.top) / rect.height, 0, 1);
      const raw = min + ratio * (max - min);
      const stepped = clamp(Math.round(raw / step) * step, min, max);
      onChange(Number(stepped.toFixed(2)));
    },
    [min, max, step, onChange],
  );

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    e.preventDefault();
    trackRef.current?.setPointerCapture(e.pointerId);
    draggingRef.current = true;
    setDragging(true);
    commit(e.clientY);
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    commit(e.clientY);
  };

  const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setDragging(false);
    if (trackRef.current?.hasPointerCapture(e.pointerId)) {
      trackRef.current.releasePointerCapture(e.pointerId);
    }
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    let next = value;
    switch (e.key) {
      case "ArrowUp":
      case "ArrowRight":
        next = value + step;
        break;
      case "ArrowDown":
      case "ArrowLeft":
        next = value - step;
        break;
      case "PageUp":
        next = value + bigStep;
        break;
      case "PageDown":
        next = value - bigStep;
        break;
      case "Home":
        next = min;
        break;
      case "End":
        next = max;
        break;
      default:
        return;
    }
    e.preventDefault();
    onChange(clamp(Number(next.toFixed(2)), min, max));
  };

  const move =
    dragging || reduce
      ? "transition-none"
      : "transition-[bottom,height] duration-200 ease-out";

  const boost = value >= 0;
  const fillColor = bipolar ? (boost ? accentUp : accentDown) : accentUp;
  const fillStyle = bipolar
    ? boost
      ? { bottom: `${centerPct}%`, height: `${pct - centerPct}%` }
      : { bottom: `${pct}%`, height: `${centerPct - pct}%` }
    : { bottom: "0%", height: `${pct}%` };

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="w-14 rounded-md bg-slate-100 py-1 text-center font-mono text-xs tabular-nums text-slate-700 dark:bg-zinc-800/80 dark:text-zinc-200">
        {format(value)}
      </div>

      <div
        ref={trackRef}
        role="slider"
        tabIndex={0}
        aria-label={`${label} band, ${freq}`}
        aria-orientation="vertical"
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={format(value)}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        className="relative h-56 w-12 cursor-grab touch-none select-none rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:cursor-grabbing dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-950"
      >
        <div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200 dark:bg-zinc-800" />

        {bipolar ? (
          <div
            className="absolute left-1/2 h-px w-6 -translate-x-1/2 bg-slate-300 dark:bg-zinc-700"
            style={{ bottom: `${centerPct}%` }}
          />
        ) : null}

        <div
          className={`absolute left-1/2 w-2 -translate-x-1/2 rounded-full ${fillColor} ${move}`}
          style={fillStyle}
        />

        <div
          className={`absolute z-10 flex h-6 w-10 items-center justify-center gap-[3px] rounded-md border border-slate-300 bg-white shadow-md ${move} dark:border-zinc-600 dark:bg-zinc-800`}
          style={{ bottom: `${pct}%`, left: "50%", transform: "translate(-50%,50%)" }}
        >
          <span className="h-2.5 w-px bg-slate-300 dark:bg-zinc-600" />
          <span className="h-2.5 w-px bg-slate-300 dark:bg-zinc-600" />
          <span className="h-2.5 w-px bg-slate-300 dark:bg-zinc-600" />
        </div>
      </div>

      <div className="flex flex-col items-center leading-tight">
        <span className="text-xs font-semibold text-slate-700 dark:text-zinc-200">
          {label}
        </span>
        <span className="font-mono text-[10px] text-slate-400 dark:text-zinc-500">
          {freq}
        </span>
      </div>
    </div>
  );
}

export default function VerticalFaderConsole() {
  const reduce = Boolean(useReducedMotion());

  const [bands, setBands] = useState<number[]>(FLAT);
  const [master, setMaster] = useState<number>(DEFAULT_MASTER);

  const setBand = useCallback((index: number, v: number) => {
    setBands((prev) => prev.map((x, i) => (i === index ? v : x)));
  }, []);

  const applyPreset = useCallback((values: number[]) => {
    setBands([...values]);
  }, []);

  const resetAll = useCallback(() => {
    setBands([...FLAT]);
    setMaster(DEFAULT_MASTER);
  }, []);

  const activePreset = useMemo<string | null>(
    () =>
      PRESETS.find((p) => p.values.every((v, i) => v === bands[i]))?.name ??
      null,
    [bands],
  );

  const { peak, level, status } = useMemo(() => {
    const pk = Math.max(...bands);
    const lvl = clamp((master / 100) * (1 + Math.max(pk, 0) / 18), 0, 1.12);
    const st =
      lvl >= 1
        ? {
            label: "Clipping",
            bar: "bg-rose-500 dark:bg-rose-400",
            text: "text-rose-600 dark:text-rose-400",
          }
        : lvl >= 0.85
          ? {
              label: "Hot",
              bar: "bg-amber-500 dark:bg-amber-400",
              text: "text-amber-600 dark:text-amber-400",
            }
          : {
              label: "Nominal",
              bar: "bg-emerald-500 dark:bg-emerald-400",
              text: "text-emerald-600 dark:text-emerald-400",
            };
    return { peak: pk, level: lvl, status: st };
  }, [bands, master]);

  const eqCss = `
@keyframes sldv-eq { 0%, 100% { transform: scaleY(0.35); } 50% { transform: scaleY(1); } }
@keyframes sldv-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.8); } }
.sldv-eq-bar { animation: sldv-eq 900ms ease-in-out infinite; transform-origin: bottom center; }
.sldv-dot { animation: sldv-pulse 1.7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .sldv-eq-bar, .sldv-dot { animation: none !important; transform: none !important; }
}
`;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-zinc-950">
      <style>{eqCss}</style>

      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 24 }}
        whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
        viewport={{ once: true, margin: "-80px" }}
        transition={{ duration: 0.6, ease: "easeOut" }}
        className="relative mx-auto max-w-5xl"
      >
        <header className="mb-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 dark:border-zinc-800 dark:bg-zinc-900">
              <span className="sldv-dot h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
              <span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500 dark:text-zinc-400">
                Studio Rack · 7-Band EQ
              </span>
              <span
                aria-hidden
                className="ml-1 flex h-4 items-end gap-[3px]"
              >
                <span className="sldv-eq-bar h-full w-1 rounded-sm bg-indigo-500 dark:bg-indigo-400" style={{ animationDelay: "0ms" }} />
                <span className="sldv-eq-bar h-full w-1 rounded-sm bg-violet-500 dark:bg-violet-400" style={{ animationDelay: "180ms" }} />
                <span className="sldv-eq-bar h-full w-1 rounded-sm bg-indigo-500 dark:bg-indigo-400" style={{ animationDelay: "360ms" }} />
                <span className="sldv-eq-bar h-full w-1 rounded-sm bg-violet-500 dark:bg-violet-400" style={{ animationDelay: "90ms" }} />
              </span>
            </div>
            <h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
              Shape the mix, band by band.
            </h2>
            <p className="mt-2 max-w-md text-sm text-slate-600 dark:text-zinc-400">
              Drag any fader, or focus it and nudge a decibel at a time with the
              arrow keys. Presets glide the whole bank into place.
            </p>
          </div>

          <button
            type="button"
            onClick={resetAll}
            className="inline-flex items-center gap-2 self-start rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:self-auto dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-950"
          >
            <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
              <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
              <path d="M3 3v5h5" />
            </svg>
            Reset all
          </button>
        </header>

        <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-zinc-800 dark:bg-zinc-900">
          <div className="mb-8 flex flex-wrap items-center gap-2">
            <span className="mr-1 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
              Presets
            </span>
            {PRESETS.map((p) => {
              const isActive = activePreset === p.name;
              return (
                <button
                  key={p.name}
                  type="button"
                  aria-pressed={isActive}
                  onClick={() => applyPreset(p.values)}
                  className={
                    "rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900 " +
                    (isActive
                      ? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-500"
                      : "border-slate-300 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-500 dark:hover:text-white")
                  }
                >
                  {p.name}
                </button>
              );
            })}
          </div>

          <div className="overflow-x-auto pb-2">
            <div className="flex min-w-max items-end justify-center gap-4 sm:gap-6">
              {BANDS.map((b, i) => (
                <Fader
                  key={b.freq}
                  label={b.label}
                  freq={b.freq}
                  value={bands[i]}
                  min={EQ_MIN}
                  max={EQ_MAX}
                  step={1}
                  bigStep={3}
                  bipolar
                  accentUp="bg-indigo-500 dark:bg-indigo-400"
                  accentDown="bg-rose-500 dark:bg-rose-400"
                  format={eqLabel}
                  onChange={(v) => setBand(i, v)}
                  reduce={reduce}
                />
              ))}

              <div className="mx-1 h-56 w-px self-center bg-slate-200 dark:bg-zinc-800" />

              <Fader
                label="Master"
                freq="Output"
                value={master}
                min={0}
                max={100}
                step={1}
                bigStep={10}
                bipolar={false}
                accentUp="bg-emerald-500 dark:bg-emerald-400"
                accentDown="bg-emerald-500 dark:bg-emerald-400"
                format={pctLabel}
                onChange={setMaster}
                reduce={reduce}
              />
            </div>
          </div>

          <div className="mt-8 flex flex-col gap-4 border-t border-slate-200 pt-6 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
            <div className="flex-1">
              <div className="mb-1.5 flex items-center justify-between text-xs">
                <span className="font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
                  Output level
                </span>
                <span className={`font-mono font-semibold ${status.text}`}>
                  {status.label}
                </span>
              </div>
              <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-800">
                <div
                  className={`h-full rounded-full ${status.bar} ${reduce ? "" : "transition-[width] duration-300 ease-out"}`}
                  style={{ width: `${clamp(level * 100, 0, 100)}%` }}
                />
              </div>
            </div>

            <dl className="flex shrink-0 items-center gap-6 font-mono text-sm">
              <div className="text-right">
                <dt className="text-[10px] uppercase tracking-wider text-slate-400 dark:text-zinc-500">
                  Master
                </dt>
                <dd className="font-semibold text-slate-900 tabular-nums dark:text-white">
                  {master}%
                </dd>
              </div>
              <div className="text-right">
                <dt className="text-[10px] uppercase tracking-wider text-slate-400 dark:text-zinc-500">
                  Peak band
                </dt>
                <dd className="font-semibold text-slate-900 tabular-nums dark:text-white">
                  {peak > 0 ? "+" : ""}
                  {peak} dB
                </dd>
              </div>
            </dl>
          </div>
        </div>
      </motion.div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →