Fader

No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  @keyframes slider-fader-l {
    0%, 100% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.62) * 100%) 0 0 0); }
    35% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.98) * 100%) 0 0 0); }
    60% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.74) * 100%) 0 0 0); }
    80% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.9) * 100%) 0 0 0); }
  }
  @keyframes slider-fader-r {
    0%, 100% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.8) * 100%) 0 0 0); }
    25% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.6) * 100%) 0 0 0); }
    55% { clip-path: inset(calc((1 - var(--slider-fader-v)) * 100%) 0 0 0); }
    75% { clip-path: inset(calc((1 - var(--slider-fader-v) * 0.7) * 100%) 0 0 0); }
  }
  .slider-fader { display: grid; justify-items: center; gap: 10px; }
  .slider-fader__desk { display: flex; align-items: stretch; gap: 10px; height: 150px; padding: 12px 14px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 8px 20px -10px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .slider-fader__scale { position: relative; width: 22px; }
  .slider-fader__mark { position: absolute; right: 0; display: flex; align-items: center; gap: 3px; transform: translateY(-50%); color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 8px; }
  .slider-fader__mark::after { content: ''; width: 4px; height: 1px; background: light-dark(#6d7179, #7d8087); }
  .slider-fader__slot { position: relative; width: 34px; }
  .slider-fader__groove { position: absolute; left: 50%; top: 9px; bottom: 9px; width: 4px; margin-left: -2px; border-radius: 2px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 1px 2px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .slider-fader__cap {
    position: absolute;
    left: 2px;
    right: 2px;
    bottom: calc(var(--slider-fader-v) * (100% - 18px));
    height: 18px;
    border-radius: 3px;
    background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087));
    box-shadow: inset 0 1px 0 light-dark(#42454c, #e5e6e9), 0 3px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
  }
  .slider-fader__cap::before { content: ''; position: absolute; left: 4px; right: 4px; top: 8px; height: 2px; background: light-dark(#ebedf0, #232529); }
  .slider-fader:has(.slider-fader__input:focus-visible) .slider-fader__cap { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .slider-fader__input {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 150px;
    height: 34px;
    margin: 0;
    transform: translate(-50%, -50%) rotate(-90deg);
    opacity: 0;
    cursor: ns-resize;
    -webkit-appearance: none;
    appearance: none;
    background: none;
  }
  .slider-fader__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 34px; }
  .slider-fader__input::-moz-range-thumb { width: 18px; height: 34px; border: 0; }
  .slider-fader__meter { display: flex; gap: 3px; }
  .slider-fader__bar { position: relative; width: 6px; border-radius: 1px; background: light-dark(#ebedf0, #232529); }
  .slider-fader__bar::before {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(to top, light-dark(#5f626a, #a7abb2), light-dark(#42454c, #e5e6e9) 70%, light-dark(#373a40, #f1f2f3));
    -webkit-mask: repeating-linear-gradient(to top, #000 0 4px, transparent 4px 6px);
    mask: repeating-linear-gradient(to top, #000 0 4px, transparent 4px 6px);
    animation: slider-fader-l 0.9s ease-in-out infinite;
  }
  .slider-fader__bar + .slider-fader__bar::before { animation-name: slider-fader-r; animation-duration: 1.1s; }
  .slider-fader__read { color: light-dark(#50535b, #c6c8cd); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-variant-numeric: tabular-nums; }
  @media (prefers-reduced-motion: reduce) {
    .slider-fader__bar::before { animation: none; clip-path: inset(calc((1 - var(--slider-fader-v) * 0.8) * 100%) 0 0 0); }
  }
`;

const MARKS = [
  { label: '+6', at: 0 },
  { label: '0', at: 0.09 },
  { label: '-10', at: 0.24 },
  { label: '-20', at: 0.39 },
  { label: '-40', at: 0.7 },
  { label: '-60', at: 1 },
];

export function FaderSlider() {
  const [v, setV] = useState(70);
  const db = v === 0 ? '-∞' : (-60 + (v / 100) * 66).toFixed(1);
  return (
    <>
      <style>
        {css}
      </style>
      <div className="slider-fader" style={{ '--slider-fader-v': v / 100 } as React.CSSProperties}>
        <div className="slider-fader__desk">
          <span className="slider-fader__scale" aria-hidden="true">
            {MARKS.map((m) => (
              <span key={m.label} className="slider-fader__mark" style={{ top: `calc(9px + ${m.at} * (100% - 18px))` }}>
                {m.label}
              </span>
            ))}
          </span>
          <span className="slider-fader__slot">
            <span className="slider-fader__groove" />
            <span className="slider-fader__cap" />
            <input
              className="slider-fader__input"
              type="range"
              min={0}
              max={100}
              value={v}
              onChange={(e) => setV(+e.target.value)}
              aria-label="Channel level"
              aria-valuetext={`${db} dB`}
            />
          </span>
          <span className="slider-fader__meter" aria-hidden="true">
            <span className="slider-fader__bar" />
            <span className="slider-fader__bar" />
          </span>
        </div>
        <span className="slider-fader__read" aria-hidden="true">
          {db} dB
        </span>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-fader
Source on GitHub

About this component

A mixing-desk channel fader: push the cap up its groove past the dB scale and the stereo meter beside it bounces up to the level you set, with the reading in decibels below.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, continuous, fill
Techniques
css variables, keyframes, clip path
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the meters hold still at the set level.

More sliders

See all
Fan
Focus
Goo
Grow