sillonsillon /si.jɔ̃/French for the groove cut into a record.

Headless waveform components for React.

Peaks, playback, regions, markers and a visualizer, as parts you compose and style with your own CSS.

npm install @sillon/react
01

Usage

useAudio loads a file and returns its peaks and a player. Pass both to Waveform.Root and put the parts you want inside it.

import { Waveform, useAudio, usePlayerState } from '@sillon/react'export function Episode({ src }) {  const { peaks, player } = useAudio(src)  const { playing } = usePlayerState(player)  return (    <div className="flex items-center gap-4">      <button onClick={player.toggle}>{playing ? 'Pause' : 'Play'}</button>      {/* Root needs a size; the parts fill it. */}      <Waveform.Root peaks={peaks} player={player} className="h-16 flex-1">        {/* Placeholder bars while the file loads, fading into the real ones */}        <Waveform.Skeleton className="bg-stone-300 opacity-50 transition-opacity" />        <Waveform.Shape className="transition-opacity in-data-[state=loading]:opacity-0">          <Waveform.Track className="bg-stone-300" />          <Waveform.Progress className="bg-stone-950" />        </Waveform.Shape>        <Waveform.Cursor className="w-0.5 bg-orange-600" />        <Waveform.Time className="absolute top-full mt-1 text-sm" />      </Waveform.Root>    </div>  )}

To read a .peaks file, or to draw a waveform with no playback, use usePeaks and usePlayer separately. The waveform appears once the whole file is read; pass progressive to draw it while a long file streams in.

02

Components

Each part does one job, and they all follow the same player. Press play and every tile below moves together.

03

Styling

Track and Progress are clipped to the bars, so any CSS background works: a color, a gradient, a pattern or an image.

04

Effects

Waveform.GL takes a few lines of GLSL that know about the waveform and the music: each bar's height, how long ago the playhead passed it, and how loud the song is right now. The sparks and dust are a small component on the same data, from useWaveform(). Nothing moves until you press play.

05

Visualizer

Visualizer draws the live spectrum of a player while it plays. It draws in the element's CSS color, so it follows your theme, dark mode included. Give it a fragment and it draws with WebGL instead, through your shader.

06

Regions

Waveform.Region marks a span of time. Drag across the waveform to mark a passage, then move it or pull its edges. player.setLoop() repeats it.

import { useEffect, useState } from 'react'import { Waveform, useAudio } from '@sillon/react'// Both grips: 8px wide, centered on their edge of the loop, with a line down the middle.const handle = `w-2 cursor-ew-resize  data-[sillon-handle=start]:-translate-x-1/2  data-[sillon-handle=end]:translate-x-1/2  after:absolute after:inset-x-[3px] after:inset-y-0  after:rounded-[1px] after:bg-accent`export function Looper({ src }) {  const { peaks, player } = useAudio(src)  const [loop, setLoop] = useState({ start: 16, end: 31 })  useEffect(() => player.setLoop(loop), [player, loop])  return (    <Waveform.Root      peaks={peaks}      player={player}      barRadius={1}      contrast={2}      onDragSelect={(start, end) => setLoop({ start, end })}      className="h-28"    >      <Waveform.Shape>        <Waveform.Track className="bg-track" />        <Waveform.Progress className="bg-ink" />        <Waveform.Region {...loop} className="bg-accent" />      </Waveform.Shape>      <Waveform.Region        {...loop}        onChange={(start, end) => setLoop({ start, end })}        className="cursor-grab touch-none bg-accent/12 active:cursor-grabbing"      >        <Waveform.RegionHandle side="start" className={handle} aria-label="Loop start" />        <Waveform.RegionHandle side="end" className={handle} aria-label="Loop end" />      </Waveform.Region>    </Waveform.Root>  )}
07

Comments

Waveform.Marker pins anything to a time. useGroups merges markers that would overlap, and useActive returns what the playhead just passed. Click an avatar to play from that comment, or a number to see everyone who commented there.

import { Popover } from '@base-ui/react/popover'import { Waveform, useActive, useAudio, useGroups } from '@sillon/react'// Avatars hang under the bars and bubbles float above them, both centered on their time.const face = 'absolute top-[calc(100%+10px)] left-0 -translate-x-1/2'const bubble = 'absolute bottom-[calc(100%+10px)] left-0 -translate-x-1/2'function Comments({ comments, player }) {  const groups = useGroups(comments, 28)  // The newest comment the playhead passed in the last 3 seconds.  const now = useActive(comments, 3).at(-1)  const playFrom = (c) => { player.seek(c.time); player.play() }  return groups.map((group) => {    const [first, ...rest] = group.items    return (      <Waveform.Marker key={group.key} time={group.time}>        {rest.length === 0 ? (          <button className={face} onClick={() => playFrom(first)}>            <Avatar user={first.user} />          </button>        ) : (          // Any popover works; sillon only says which comments share a spot.          <Popover.Root>            <Popover.Trigger className={face}>              <Avatar user={first.user} />              <span className="absolute -top-1.5 -right-[9px]">{group.items.length}</span>            </Popover.Trigger>            <Popover.Portal>              <Popover.Positioner side="bottom">                <Popover.Popup className="max-h-70 overflow-y-auto">                  {group.items.map((c) => (                    <button key={c.id} onClick={() => playFrom(c)}>                      <Avatar user={c.user} /> {c.text}                    </button>                  ))}                </Popover.Popup>              </Popover.Positioner>            </Popover.Portal>          </Popover.Root>        )}        {group.items.includes(now) && <p className={bubble}>{now.text}</p>}      </Waveform.Marker>    )  })}export function Track({ src, comments }) {  const { peaks, player } = useAudio(src)  return (    <Waveform.Root peaks={peaks} player={player} barRadius={1} contrast={2} className="h-24">      <Waveform.Shape>        <Waveform.Track className="bg-track" />        <Waveform.Progress className="bg-ink" />      </Waveform.Shape>      <Comments comments={comments} player={player} />    </Waveform.Root>  )}
08

Long files

An hour-long recording, from peaks made ahead of time: a 302 kB .peaks file instead of 55 MB of audio. Pinch to zoom (Ctrl+scroll with a mouse); scroll sideways or drag to pan. useZoom adds these gestures, and store.setView(start, end) sets the visible range.

Decoding and the .peaks format live in @sillon/core, which installs with @sillon/react and also works without React: make .peaks files with Peaks.encode(), or load peaks for your own drawing with streamPeaks().

import { Waveform, usePeaks, useWaveformStore, useZoom } from '@sillon/react'const SPANS = [  { label: '1 h', seconds: 3600 },  { label: '10 min', seconds: 600 },  { label: '1 min', seconds: 60 },  { label: '5 s', seconds: 5 },]export function Zoomable() {  // Peaks made ahead of time: a .peaks file is read as is, no audio decoded.  const { peaks } = usePeaks('/audio/hour.peaks')  const store = useWaveformStore()  // Pinch or Ctrl+scroll zooms; scrolling sideways or dragging pans. None of it re-renders.  const zoom = useZoom(store, { min: 5, drag: true })  // The demo also eases between spans and adds an overview:  // https://github.com/mattrothenberg/sillon/blob/main/apps/site/src/islands/Zoom.tsx  return (    <>      {SPANS.map((s) => (        <button key={s.seconds} onClick={() => zoom.zoomTo(s.seconds)}>{s.label}</button>      ))}      <Waveform.Root ref={zoom.ref} store={store} peaks={peaks} barRadius={1} interactive={false} className="h-40 touch-pan-y">        <Waveform.Shape>          <Waveform.Track className="bg-ink" />        </Waveform.Shape>      </Waveform.Root>    </>  )}
09

Recording

useRecorder records from the microphone and returns peaks that fill in as you speak, so the waveform draws while you record. When you stop, it hands back the audio to play or upload, and the peaks are complete.

import { useEffect } from 'react'import { Waveform, usePlayer, usePlayerState, useRecorder, useWaveformStore } from '@sillon/react'export function VoiceNote() {  const rec = useRecorder()  const recording = rec.state === 'recording'  // Plays the recording once it's stopped.  const player = usePlayer(rec.url ?? undefined)  const { playing } = usePlayerState(player)  // The last 10 seconds while recording, the whole recording after.  const store = useWaveformStore()  useEffect(() => rec.peaks?.subscribe(() => {    const end = rec.peaks!.duration    if (rec.peaks!.complete) store.setView(0, null)    else store.setView(Math.max(0, end - 10), Math.max(10, end))  }), [rec.peaks, store])  return (    <>      <button onClick={recording ? rec.stop : rec.start}>{recording ? 'Stop' : 'Record'}</button>      <button onClick={player.toggle} disabled={!rec.url}>{playing ? 'Pause' : 'Play'}</button>      <Waveform.Root store={store} peaks={rec.peaks} player={rec.url ? player : null} progressive className="h-28">        <Waveform.Shape>          <Waveform.Track className={recording ? 'bg-accent' : 'bg-track'} />          <Waveform.Progress className="bg-ink" />        </Waveform.Shape>      </Waveform.Root>    </>  )}