Svelte Audio UI
DocsParticles
0
Overview
  • Introduction
  • Get Started
Components
  • Audio Player
  • Audio Provider
  • Audio Queue
  • Audio Track
  • Audio Playback Speed
UI
  • Fader
  • Knob
  • Slider
  • Sortable List
  • XY Pad
Libs
  • Audio Store
  • HTML Audio
Resources
  • llms.txt
  • llms-full.txt

On This Page

InstallationUsageHorizontal FaderCustom Thumb MarksNo Thumb MarksAPI ReferenceFaderPropsBehaviorExamplesVolume ControlMultiple Faders (Mixing Board)Related

Fader

Previous Next

A slider-style fader component for audio mixing interfaces. Supports vertical and horizontal orientations, size variants, and customisable thumb marks.


A drag-to-adjust fader built entirely from plain HTML elements and Tailwind — no external slider primitives. Ideal for volume controls, mixing boards, or any parameter you want to expose in a tactile, studio-style UI. Supports vertical and horizontal orientations, three size variants, and configurable thumb marks.

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
  let value = $state(50);
</script>
 
<div class="h-48">
  <Fader {value} onValueChange={(e) => (value = e)} />
</div>

Installation

pnpm dlx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/fader.json
npx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/fader.json
npx shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/fader.json
bun x shadcn-svelte@latest add https://svelte-audio-ui.vercel.app/r/fader.json

Copy and paste the following code into your project.

<script lang="ts">
  import { cn } from "$lib/utils.js";

  type Orientation = "horizontal" | "vertical";
  type Size = "sm" | "default" | "lg";

  interface Props {
    value?: number; // 0–100
    min?: number;
    max?: number;
    step?: number;
    disabled?: boolean;
    orientation?: Orientation;
    size?: Size;
    thumbMarks?: number | false;
    class?: string;
    label?: string;
    onValueChange?: (value: number) => void;
  }

  let {
    value = 50,
    min = 0,
    max = 100,
    step = 1,
    disabled = false,
    orientation = "vertical",
    size = "default",
    thumbMarks = 3,
    class: className = "",
    label,
    onValueChange,
  }: Props = $props();

  // ── Derived geometry ────────────────────────────────────────────────────────
  const isVertical = $derived(orientation === "vertical");
  const fillPercent = $derived(((value - min) / (max - min)) * 100);

  // ── Track dimensions ────────────────────────────────────────────────────────
  const trackSize = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "w-2" : "h-2",
    default: isVertical ? "w-3" : "h-3",
    lg: isVertical ? "w-4" : "h-4",
  }));

  // ── Thumb dimensions ────────────────────────────────────────────────────────
  const thumbW = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "w-7" : "w-4",
    default: isVertical ? "w-9" : "w-5",
    lg: isVertical ? "w-11" : "w-6",
  }));
  const thumbH = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "h-4" : "h-7",
    default: isVertical ? "h-5" : "h-9",
    lg: isVertical ? "h-6" : "h-11",
  }));

  const thumbWStyles = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "28px" : "16px",
    default: isVertical ? "36px" : "20px",
    lg: isVertical ? "44px" : "24px",
  }));
  const thumbHStyles = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "16px" : "28px",
    default: isVertical ? "20px" : "36px",
    lg: isVertical ? "24px" : "44px",
  }));
  const cssVars = $derived(`--thumb-w: ${thumbWStyles[size]}; --thumb-h: ${thumbHStyles[size]};`);

  // ── Thumb mark dimensions ───────────────────────────────────────────────────
  const markW = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "w-3" : "w-px",
    default: isVertical ? "w-4" : "w-px",
    lg: isVertical ? "w-5" : "w-px",
  }));
  const markH = $derived.by<Record<Size, string>>(() => ({
    sm: isVertical ? "h-px" : "h-3",
    default: isVertical ? "h-px" : "h-4",
    lg: isVertical ? "h-px" : "h-5",
  }));

  // ── Thumb position as CSS ───────────────────────────────────────────────────
  // Vertical: thumb slides from bottom (0%) to top (100%)
  // Horizontal: thumb slides from left (0%) to right (100%)
  const thumbStyle = $derived(
    isVertical
      ? `bottom: calc(${fillPercent}% - 10px)` // offset by half thumb height ≈ 10 px
      : `left: calc(${fillPercent}% - 10px)`
  );

  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
    onValueChange?.(Number(e.currentTarget.value));
  }
</script>

<div
  class={cn(
    "relative flex touch-none select-none",
    isVertical
      ? "h-full min-h-32 w-full flex-col items-center justify-center"
      : "h-full min-w-32 flex-row items-center",
    disabled && "pointer-events-none cursor-not-allowed opacity-50",
    className
  )}
  style={cssVars}
  data-disabled={disabled}
  role="presentation"
>
  {#if label}
    <span class="text-muted-foreground mb-1 text-xs">{label}</span>
  {/if}

  <!-- Invisible native range input (handles all pointer/keyboard interaction) -->
  <input
    type="range"
    {min}
    {max}
    {step}
    {value}
    {disabled}
    aria-label={label ?? "Fader"}
    aria-orientation={orientation}
    class={cn(
      "peer absolute inset-0 z-20 cursor-grab opacity-0",
      "active:cursor-grabbing",
      isVertical
        ? "h-full w-full [direction:rtl] [writing-mode:vertical-lr]"
        : "h-full w-full",
      disabled && "cursor-not-allowed"
    )}
    oninput={handleInput}
  />

  <!-- Track -->
  <div
    class={cn(
      "bg-muted pointer-events-none relative z-10 overflow-hidden rounded-full",
      isVertical ? `h-full ${trackSize[size]}` : `w-full ${trackSize[size]}`
    )}
  >
    <!-- Fill -->
    <div
      class={cn("bg-primary absolute", isVertical ? "bottom-0 w-full" : "left-0 h-full")}
      style={isVertical ? `height: ${fillPercent}%` : `width: ${fillPercent}%`}
    ></div>
  </div>

  <!-- Thumb -->
  <div
    class={cn(
      "pointer-events-none absolute z-10 flex shrink-0 items-center justify-center",
      "border-border bg-card rounded-md border shadow-sm",
      "transition-[border-color,box-shadow] duration-150 ease-out outline-none",
      "peer-hover:ring-ring/50 peer-hover:ring-2",
      "peer-active:border-ring peer-active:ring-ring peer-active:ring-2",
      thumbW[size],
      thumbH[size],
      isVertical ? "left-1/2 -translate-x-1/2" : "top-1/2 -translate-y-1/2"
    )}
    style={thumbStyle}
  >
    <!-- Thumb marks -->
    <div
      class={cn(
        "pointer-events-none flex h-full w-full items-center justify-center gap-0.5",
        isVertical ? "flex-col" : "flex-row"
      )}
    >
      {#if thumbMarks !== false}
        {#each { length: thumbMarks } as _, i (i)}
          <div
            class={cn("bg-muted-foreground opacity-30", markW[size], markH[size])}
          ></div>
        {/each}
      {/if}
    </div>
  </div>
</div>

<style>
  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    background: transparent;
  }

  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: var(--thumb-w, 20px);
    height: var(--thumb-h, 20px);
    cursor: grab;
    background: transparent;
    border: none;
  }

  input[type="range"]:active::-webkit-slider-thumb {
    cursor: grabbing;
  }

  input[type="range"]::-moz-range-thumb {
    width: var(--thumb-w, 20px);
    height: var(--thumb-h, 20px);
    cursor: grab;
    background: transparent;
    border: none;
  }

  input[type="range"]:active::-moz-range-thumb {
    cursor: grabbing;
  }
</style>

Usage

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
 
  let value = $state(50);
</script>
<Fader {value} onValueChange={(e) => (value = e)} />

Horizontal Fader

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
 
  let value = $state(50);
</script>
 
<div class="flex w-full max-w-sm flex-col items-center gap-4">
  <Fader orientation="horizontal" {value} onValueChange={(e) => (value = e)} />
  <span class="text-muted-foreground text-sm tabular-nums">{value}</span>
</div>

Custom Thumb Marks

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
 
  let value = $state(50);
</script>
 
<div class="flex h-48 items-center justify-center gap-8">
  <Fader thumbMarks={1} {value} onValueChange={(e) => (value = e)} label="1" />
  <Fader thumbMarks={3} onValueChange={(e) => (value = e)} {value} label="3" />
  <Fader thumbMarks={5} onValueChange={(e) => (value = e)} {value} label="5" />
</div>

No Thumb Marks

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
 
  let value = $state(50);
</script>
 
<div class="flex h-48 items-center justify-center">
  <Fader thumbMarks={false} {value} onValueChange={(e) => (value = e)} />
</div>

API Reference

Fader

Props

Prop Type Default Description
value number 50 Current value (bindable).
min number 0 Minimum value.
max number 100 Maximum value.
step number 1 Step increment.
disabled boolean false Disables all interaction and adds reduced-opacity styling.
orientation "horizontal" | "vertical" "vertical" Fader orientation.
size "sm" | "default" | "lg" "default" Controls the track width and thumb dimensions.
thumbMarks number | false 3 Number of grip lines on the thumb, or false to hide them.
label string - Optional text label rendered above the fader.
onValueChange (value: number) => void - Fires on every input event as the user drags.
class string - Additional CSS classes on the root wrapper.

Behavior

  • Interaction is handled by an invisible native <input type="range"> stretched over the full component — keyboard and pointer events just work, fully accessible.
  • Vertical mode uses writing-mode: vertical-lr + direction: rtl on the hidden input so dragging upward increases the value, matching physical fader muscle memory.
  • The fill and thumb position are driven by derived state — no manual DOM manipulation needed.

Tip: Use bind:value for two-way binding in Svelte, or pass value + onValueChange for a controlled setup.

Examples

Volume Control

Wire directly to audioStore for a store-driven volume fader:

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
  import { audioStore } from "$lib/audio-store.svelte.js";
 
  function handleVolumeChange(value: number) {
    audioStore.setVolume({ volume: value / 100 });
  }
</script>
 
<div class="flex h-48 items-center justify-center gap-4">
  <Fader
    label="Vol"
    value={audioStore.volume * 100}
    onValueChange={handleVolumeChange}
  />
</div>

Multiple Faders (Mixing Board)

Loading...

<script lang="ts">
  import { Fader } from "$lib/components/ui/audio/elements/fader/index.js";
 
  let channels = $state([
    { label: "Kick", value: 75 },
    { label: "Snare", value: 60 },
    { label: "Bass", value: 80 },
    { label: "Vox", value: 90 },
    { label: "FX", value: 40 }
  ]);
</script>
 
<div class="flex h-56 items-end justify-center gap-4">
  {#each channels as channel (channel.label)}
    <div class="flex flex-col items-center gap-2">
      <Fader
        value={channel.value}
        label={channel.label}
        size="sm"
        onValueChange={(e) => (channel.value = e)}
      />
      <span class="text-muted-foreground text-xs tabular-nums"
        >{channel.value}</span
      >
    </div>
  {/each}
</div>

Related

  • Audio Player — player controls to pair with faders
  • Audio Provider — audio context and state management
Audio Playback Speed Knob
Built by ddtamn. The source code is available on Github