MediaTrimmer
A trim range drawn over a filmstrip, with an optional playhead and ruler — plus MediaSeekBar, a scrub bar with a hover preview frame. Three separately named sliders, full keyboard operation, and min/max selection length.
Playground
Installation
npx shadcn@latest add https://tessinaui.com/r/media-trimmer.jsonUsage
import { MediaTrimmer, MediaSeekBar } from "@tessinaui/ui";<MediaTrimmer
duration={30}
value={range}
onValueChange={setRange}
defaultPlayhead={12}
minDuration={1}
/>When to use it
MediaTrimmer picks a range out of a clip — the filmstrip trim every video
editor ships (Pinterest, Vimeo, TikTok, Whatnot in
ledgers/slider_VARIANT_LEDGER.md, row 12). MediaSeekBar picks a point in
one — the player scrub bar with a preview bubble (Netflix, Disney+, Tubi).
Neither is a Slider variant. The media rendering and the scrub mechanics are their own object: a slider has one value per thumb and no opinion about frames, while a trimmer has a selection, a playback position and a strip of images that all have to stay in step.
Examples
Default
A trim range with a playhead. Clicking the strip seeks; dragging a handle trims.
Ruler and constraints
A longer clip with timecode ticks, and a selection held between 10 s and 3 min. Both limits are enforced by moving the handle you are dragging — a trimmer that drags the far end along with your finger loses the edit you already made.
Sizes
Seek bar
A scrub bar with a buffered range and a preview bubble. previewAt returns the
still for a position, or null when you have none — the bubble then shows the
timecode alone rather than a broken image.
Accessibility
- The trimmer is a
role="group"containing three separately named sliders: trim start, trim end, and playback position. They answer different questions, and a single control with modes could not tell a screen-reader user which one was about to move. - Every handle announces a timecode, not raw seconds —
aria-valuetextis1:15, or1:15.0when the step is finer than a second. - The filmstrip and the ruler are
aria-hidden. They are visual scales, and every value they show is already in a handle'saria-valuetext; announcing them would add "image, image, image" and nothing else. - Keyboard: arrows step, Shift+arrow and PageUp/PageDown take the large step, Home/End jump to the clip bounds.
- Handles are ~12px wide because they have to sit on the cut, so the 44px touch target is an invisible centred pad on phones (WCAG 2.5.5).
- Without frames the strip draws placeholder cells rather than collapsing, so the control does not change height when thumbnails decode and take the handles with it.
Time runs left-to-right at every dir. A filmstrip is a sequence of real
frames in capture order, so mirroring the track under RTL would play the clip
backwards to look correct. The surrounding labels and layout still follow the
document direction.
API Reference
MediaTrimmer
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | — | Required. Total length of the media, in seconds |
value | [number, number] | — | Controlled selection, in seconds |
defaultValue | [number, number] | whole clip | Uncontrolled initial selection |
onValueChange | (value) => void | — | Fires on every change |
onValueCommitted | (value) => void | — | Fires once when a drag ends, or immediately for a keyboard step |
minDuration | number | step | Shortest selection the handles may produce |
maxDuration | number | — | Longest selection the handles may produce |
step | number | 0.1 | Granularity, in seconds |
largeStep | number | step × 10 | Step for PageUp/PageDown and Shift+arrow |
playhead | number | — | Controlled playback position. Omit both playhead props to hide it |
defaultPlayhead | number | — | Uncontrolled initial position |
onPlayheadChange | (time) => void | — | Fires when the playhead moves |
frames | MediaFrame[] | — | Stills to draw behind the track |
showRuler | boolean | false | Tick marks and timecodes above the strip |
showDuration | boolean | true | Read-out of the selected length below the strip |
size | "sm" | "md" | "lg" | "md" | Strip height — 44 / 56 / 72px |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Handle and selection colour |
formatTime | (seconds: number) => string | timecode | Human text for the read-out and aria-valuetext |
label | string | "Trim media" | Group name |
handleLabels | [string, string] | ["Trim start", "Trim end"] | Names for the two trim handles |
playheadLabel | string | "Playback position" | Name for the playhead |
disabled | boolean | false | — |
readOnly | boolean | false | Focusable, not changeable |
MediaSeekBar
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | — | Required. Total length, in seconds |
value | number | — | Controlled playback position |
defaultValue | number | 0 | Uncontrolled initial position |
onValueChange | (time) => void | — | Fires on every change |
onValueCommitted | (time) => void | — | Fires once when a scrub ends |
buffered | number | — | How far the media has buffered, drawn behind the played fill |
step | number | 1 | Granularity, in seconds |
largeStep | number | step × 10 | Step for PageUp/PageDown and Shift+arrow |
previewAt | (seconds) => string | null | — | Still for a hovered or scrubbed position. null shows the timecode alone |
showPreview | boolean | true | Suppress the preview bubble entirely |
size | "sm" | "md" | "lg" | "md" | Track thickness |
intent | MediaTrimmerIntent | "none" | Fill colour |
formatTime | (seconds: number) => string | timecode | Human text |
label | string | "Seek" | Accessible name |
disabled | boolean | false | — |
MediaFrame
| Field | Type | Description |
|---|---|---|
src | string | Image URL |
time | number | Where in the media the frame came from |
alt | string | Unused by the strip, which is decorative — kept for consumers reusing the type |
formatTimecode
formatTimecode(seconds, precise?) — 0:09, 1:15, 1:01:01. With precise,
keeps one decimal under ten minutes.
MediaTrimmerSkeleton · MediaSeekBarSkeleton
Placeholders whose boxes come from the components' own height maps, so the two cannot drift.