Timeline
A standalone trim / playhead / ruler primitive. Composes with Media Player but doesn’t require one — useful for any “set in/out over a duration” UI (audio editor, video trimmer, range slice picker, transcript scrubber). Per the letbe-ds pluggable engines rule, we ship the chrome and state machine; bring your own waveform renderer (WaveSurfer.js, Peaks.js) or frame-accurate scrub engine (requestVideoFrameCallback) and attach.
a11y (built in): the two trim handles and the playhead are each role="slider" with tabindex="0", aria-orientation="horizontal", an aria-label (Trim start / Trim end / Playhead) and live aria-valuemin/max/now — plus aria-valuetext written as the same formatted clock string the readout shows, so screen readers announce a time rather than a raw seconds count. Keyboard mirrors the drag: ←/→ nudge one second, Shift makes it ten, Home/End jump to the bounds, all clamped by the same per-handle rules as pointer drag (in ≤ out ≤ duration). Zoom is a native input[type=range] with its own aria-label, and its multiplier readout is an aria-live="polite" region.
Usage: Load js/lb.js first, then js/components/lb-timeline.js — the module self-registers via LB.register and every <div data-lb-timeline> auto-inits, rendering its own readout, ruler, lane and zoom control (instance at el._lbTimeline, class at LB.Timeline; lb.js console-warns if it finds the attribute without the module). Seed state declaratively, all in seconds — data-lb-duration, data-lb-in, data-lb-out, data-lb-playhead — plus opt-in data-lb-timeline-wheel-zoom for Ctrl+scroll zoom. Three bubbling events: lb-timeline-change (detail.inSec / outSec / playheadSec / duration) on every edit, lb-timeline-selection (adds detail.rect — the on-screen rect of the in–out span, for anchoring a popover composer), and lb-timeline-zoom (detail.zoom). The getter/setter surface (getRange / setRange, setPlayhead, setDuration) is spelled out under Public API below, and setZoom(z) rounds out the instance; binding to a media clock stays consumer code — see the composition demo.
Opt in with data-lb-timeline on a wrapper. The controller renders a readout row (in / out / duration), a time-axis ruler with auto-scaled ticks, and a lane with trim handles + a playhead marker. Time format auto-scales by total duration: M:SS.mmm below a minute, MM:SS below an hour, HH:MM:SS beyond.
Pointer drag + keyboard nudge on all three sliders, click-on-lane scrubbing, role="slider" ARIA throughout, 1×–10× horizontal zoom with adaptive ruler tick density, and an end-to-end Media Player composition example at the bottom showing the audio <-> timeline binding pattern (rAF-driven playhead, trim-region loop, no feedback loops between the two clocks).
Default
A 2-minute clip with an in/out window selected and the playhead parked partway through. The auto-scale picks MM:SS since the total duration sits between a minute and an hour.
Short clip — sub-second precision
When the total duration is under a minute, the readout switches to M:SS.mmm for sub-second precision — the right format for clipping voice notes, drum loops, or animation frames. The ruler densifies its ticks the same way.
Long form — podcast or interview
Above an hour, the readout uses HH:MM:SS and the ruler caps major-tick density at ~6–10 across the visible width so the labels stay legible.
Empty — no media attached
Without a duration, the lane reads as a dashed placeholder — a ready-to-receive state rather than a broken one. Call el._lbTimeline.setDuration(seconds) to populate it from a consumer hook (the media’s loadedmetadata event, for instance).
Composing with Media Player
Timeline doesn’t depend on Media Player — but composing the two is the most common use case. The pattern: subscribe Timeline’s playhead to the media’s clock (via requestAnimationFrame for a smooth playhead, not just the timeupdate event which ticks ~4×/sec), push currentTime back when the user drags, and loop between the trim handles when playback reaches the out point.
Try it: press Play, then drag the trim handles to clip the loop region. Drag the playhead to scrub; click the lane to jump.
// Sketch of the wiring used in this demo. Substitute your own
// element ids and tune the loop / scrubbing rules to taste.
(function () {
var media = document.getElementById('tl-compose-media');
var tl = document.getElementById('tl-compose-tl');
var audio = media.querySelector('audio');
var ph = tl.querySelector('[data-lb-timeline-playhead]');
function init() {
// 1. Once metadata loads, populate Timeline duration + open the
// trim window across the full clip.
var apply = function () {
if (!isFinite(audio.duration)) return;
tl._lbTimeline.setDuration(audio.duration);
tl._lbTimeline.setRange({ inSec: 0, outSec: audio.duration });
};
if (audio.readyState >= 1) apply();
else audio.addEventListener('loadedmetadata', apply);
// 2. While playing, drive the playhead via rAF so it tracks
// smoothly (timeupdate is ~4×/sec, choppy for editing).
// Suspend loop bounds while the user is adjusting in/out so
// OUT < currentTime doesn't yank audio back every frame mid-drag.
var fromAudio = false;
var rafId = null;
var lastPushedSec = null;
var inH = tl.querySelector('[data-lb-timeline-handle="in"]');
var outH = tl.querySelector('[data-lb-timeline-handle="out"]');
function userAdjustingTrim() {
return (inH && inH.classList.contains('lb-timeline__handle--dragging'))
|| (outH && outH.classList.contains('lb-timeline__handle--dragging'));
}
function tick() {
if (audio.paused) { rafId = null; return; }
if (!userAdjustingTrim()) {
var r = tl._lbTimeline.getRange();
if (audio.currentTime >= r.outSec - 0.02) audio.currentTime = r.inSec;
if (audio.currentTime < r.inSec) audio.currentTime = r.inSec;
}
// Don't fight the user if they're dragging the playhead.
if (!ph.classList.contains('lb-timeline__playhead--dragging')) {
fromAudio = true;
tl._lbTimeline.setPlayhead(audio.currentTime);
fromAudio = false;
}
rafId = requestAnimationFrame(tick);
}
audio.addEventListener('play', function () { if (!rafId) rafId = requestAnimationFrame(tick); });
audio.addEventListener('pause', function () { if (rafId) cancelAnimationFrame(rafId); rafId = null; });
// 3. Timeline → audio. Skip our own audio→playhead push (fromAudio
// guard) AND trim-handle events that keep playheadSec unchanged
// (dedup) — both would otherwise glitch audio.currentTime during
// in/out adjustment.
tl.addEventListener('lb-timeline-change', function (e) {
if (fromAudio) return;
if (!isFinite(e.detail.playheadSec)) return;
if (lastPushedSec === e.detail.playheadSec) return;
lastPushedSec = e.detail.playheadSec;
audio.currentTime = e.detail.playheadSec;
});
}
if (media._lbMedia && tl._lbTimeline) init();
else document.addEventListener('DOMContentLoaded', init);
})();
Public API
Read and write Timeline state from consumer code; writes repaint the lane and emit lb-timeline-change.
// Read
el._lbTimeline.getRange(); // { inSec, outSec }
el._lbTimeline.getPlayhead(); // number (seconds)
el._lbTimeline.getDuration(); // number (seconds)
// Write — paints + emits lb-timeline-change
el._lbTimeline.setRange({ inSec: 5, outSec: 30 });
el._lbTimeline.setPlayhead(12.5);
el._lbTimeline.setDuration(180); // call after loadedmetadata
// Subscribe
el.addEventListener('lb-timeline-change', (e) => {
const { inSec, outSec, playheadSec, duration } = e.detail;
});