Mixer Faders
A small mixing desk: vertical faders with dB readouts, mute and solo per channel, live level meters and a master.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/mixer-faders.json'use client'
import { useEffect, useRef, useState } from 'react'
import './mixer-faders.css'
export interface MixChannel { id: string; name: string; db: number; mute?: boolean; solo?: boolean }
export interface MixerFadersProps {
channels: MixChannel[]
meters?: boolean
onChange?: (c: MixChannel[]) => void
fill?: 'dark' | 'light'
className?: string
}
const MIN = -60, MAX = 6
const toF = (db: number) => (db <= MIN ? 0 : Math.round(((db - MIN) / (MAX - MIN)) ** 1.6 * 10000) / 10000)
const fromF = (f: number) => (f <= 0.005 ? MIN : Math.round(MIN + Math.pow(f, 1 / 1.6) * (MAX - MIN)))
const lbl = (db: number) => (db <= MIN ? '−∞' : `${db > 0 ? '+' : ''}${db}`)
/** Channel faders with mute, solo and meters. */
export function MixerFaders({ channels, meters = true, onChange, fill = 'dark', className }: MixerFadersProps) {
const [ch, setCh] = useState(channels)
const [master, setMaster] = useState(0)
const [lv, setLv] = useState<number[]>(() => channels.map(() => 0.4))
const t = useRef(0)
const commit = (n: MixChannel[]) => { setCh(n); onChange?.(n) }
const anySolo = ch.some((c) => c.solo)
const live = (c: MixChannel) => !c.mute && (!anySolo || c.solo)
useEffect(() => {
if (!meters) return
const id = setInterval(() => { t.current++; setLv(ch.map((_, i) => 0.55 + 0.35 * Math.abs(Math.sin(t.current * (0.37 + i * 0.13) + i)) * (0.6 + 0.4 * ((t.current * (i + 3)) % 7) / 7))) }, 110)
return () => clearInterval(id)
}, [meters, ch])
const fader = (db: number, name: string, set: (v: number) => void) => {
const drag = (e: React.PointerEvent<HTMLDivElement>) => { const r = e.currentTarget.getBoundingClientRect(); set(fromF(Math.max(0, Math.min(1, 1 - (e.clientY - r.top - 4) / (r.height - 8))))) }
return (
<div className="sd-mix__f" role="slider" tabIndex={0} aria-label={`${name} level`} aria-orientation="vertical" aria-valuemin={MIN} aria-valuemax={MAX} aria-valuenow={db} aria-valuetext={`${lbl(db)} dB`}
onPointerDown={(e) => { e.currentTarget.setPointerCapture?.(e.pointerId); drag(e) }} onPointerMove={(e) => e.buttons && drag(e)}
onKeyDown={(e) => { const d = (e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0) * (e.shiftKey ? 6 : 1); if (d) { e.preventDefault(); set(Math.max(MIN, Math.min(MAX, (db <= MIN && d > 0 ? -40 : db + d)))) } }}>
<u style={{ top: `calc(4px + (100% - 8px) * ${1 - toF(0)})` }} />
<b style={{ top: `calc(4px + (100% - 8px) * ${1 - toF(db)})` }} />
</div>
)
}
const mg = toF(master)
return (
<div className={['sd-mix', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-mix__h"><b>Mix</b><span>{anySolo ? 'Solo active' : `${ch.filter(live).length} of ${ch.length} live`}</span></div>
<div className="sd-mix__row">
{ch.map((c, i) => {
const on = live(c), level = on ? lv[i] * toF(c.db) * Math.min(1, mg * 1.15) : 0
return (
<div key={c.id} className="sd-mix__ch" data-silent={!on || undefined}>
<span className="sd-mix__db">{lbl(c.db)}</span>
<div className="sd-mix__tr"><span className="sd-mix__mt" aria-hidden><i style={{ height: `${level * 100}%` }} /></span>{fader(c.db, c.name, (v) => commit(ch.map((x) => (x.id === c.id ? { ...x, db: v } : x))))}</div>
<div className="sd-mix__ms">
<button type="button" aria-pressed={!!c.mute} aria-label={`Mute ${c.name}`} onClick={() => commit(ch.map((x) => (x.id === c.id ? { ...x, mute: !x.mute } : x)))}>M</button>
<button type="button" aria-pressed={!!c.solo} aria-label={`Solo ${c.name}`} onClick={() => commit(ch.map((x) => (x.id === c.id ? { ...x, solo: !x.solo } : x)))}>S</button>
</div>
<span className="sd-mix__n">{c.name}</span>
</div>
)
})}
<div className="sd-mix__ch master">
<span className="sd-mix__db">{lbl(master)}</span>
<div className="sd-mix__tr"><span className="sd-mix__mt" aria-hidden><i style={{ height: `${Math.max(...ch.map((c, i) => (live(c) ? lv[i] * toF(c.db) : 0))) * mg * 100}%` }} /></span>{fader(master, 'Master', setMaster)}</div>
<div className="sd-mix__ms" style={{ height: 20 }} />
<span className="sd-mix__n">Master</span>
</div>
</div>
</div>
)
}.sd-mix { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mix[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mix :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mix :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mix .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mix { width: min(100%, 350px); padding: 12px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-mix__h { display: flex; justify-content: space-between; font-size: 12px; color: var(--mute); padding: 0 2px; }
.sd-mix__h b { color: var(--fg); font-size: 13.5px; }
.sd-mix__row { display: flex; gap: 6px; }
.sd-mix__ch { flex: 1; min-width: 0; display: grid; justify-items: center; gap: 6px; padding: 8px 0; border-radius: 12px; background: var(--soft); }
.sd-mix__ch.master { flex: 1.2; background: transparent; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-mix__ch[data-silent] .sd-mix__tr { opacity: 0.35; }
.sd-mix__db { font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-mix__tr { position: relative; display: flex; gap: 5px; height: 170px; transition: opacity 150ms; }
.sd-mix__mt { width: 4px; border-radius: 2px; background: var(--line); position: relative; overflow: hidden; }
.sd-mix__mt i { position: absolute; left: 0; right: 0; bottom: 0; background: var(--fg); transition: height 90ms linear; }
.sd-mix__f { position: relative; width: 22px; touch-action: none; cursor: ns-resize; outline: 0; }
.sd-mix__f::before { content: ''; position: absolute; left: 50%; top: 4px; bottom: 4px; width: 2px; margin-left: -1px; background: var(--line); }
.sd-mix__f b { position: absolute; left: 0; width: 22px; height: 12px; margin-top: -6px; border-radius: 3px; background: var(--fg); box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--bg) 40%, transparent); }
.sd-mix__f:focus-visible b { box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--fg); }
.sd-mix__f u { position: absolute; left: 50%; width: 10px; margin-left: -5px; border-top: 1px solid var(--mute); text-decoration: none; }
.sd-mix__ms { display: flex; gap: 3px; }
.sd-mix__ms button { width: 22px; height: 20px; border-radius: 5px; font-size: 10.5px; font-weight: 800; box-shadow: inset 0 0 0 1px var(--line); }
.sd-mix__ms button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-mix__n { font-size: 11px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 2px; }
@media (prefers-reduced-motion: reduce) { .sd-mix *, .sd-mix *::before, .sd-mix *::after { animation: none !important; transition: none !important; } }Overview
Each channel has a vertical fader from −∞ to +6 dB, a mute and a solo. Solo silences every channel that is not soloed, the way a real desk does.
Meters bounce with a simulated signal scaled by the fader; the master fader scales them all.
When to use
- Audio apps, streaming setups, podcast tools.
When not to use
- Precise automation: use a DAW.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/mixer-faders.json$
npx shadcn@latest add https://sundaeui.dev/r/mixer-faders.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/mixer-faders.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/mixer-faders.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy mixer-faders.tsx and mixer-faders.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MixerFaders } from '@/components/sundae/mixer-faders'
<MixerFaders />Props
| Prop | Type | Default |
|---|---|---|
channels*Channels. | { id: string; name: string; db: number }[] | — |
onChangeCalled on change. | (c) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Faders are vertical sliders with dB value text; arrow keys step by 1 dB.
- Mute and solo are toggle buttons per channel.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mix; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move a focused fader by 1 dB
- Shift + ↑ / ↓
- By 6 dB
FAQ
- Is Mixer Faders free?
- Yes. Mixer Faders is free for personal and commercial projects, with full source on this page.
- How do I install Mixer Faders?
- Run npx shadcn@latest add https://sundaeui.dev/r/mixer-faders.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Mixer Faders support keyboard and screen readers?
- Faders are vertical sliders with dB value text; arrow keys step by 1 dB. Mute and solo are toggle buttons per channel.
- Does Mixer Faders respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Mini Player
Components · Expressive Voice Note
Components · Refined- Pro
Voice Recorder
Components · Refined Podcast Chapters
Components · RefinedSynced Lyrics
Components · RefinedToast Stack
Components · Refined