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.
pnpm add @tessinaui/uiWhen to use
- A range cut from a clip. Use
MediaTrimmerto pick the start and end of the part to keep, with the frames visible while you drag. - A single point in a clip. Use
MediaSeekBarfor the player scrub bar, with a preview frame as you hover or drag. - A selection with limits. Set
minDurationandmaxDurationand the handle you are dragging stops at the limit. - A playhead beside the trim. Pass
playheadordefaultPlayheadso the trim range and the playback position stay in step. - A range that can be invalid. Pass
errorMessageto say why, so both handles carry the reason.
When not to use
- For a plain number range. A price or a volume has no frames to show. Use a Slider.
- For a time of day or a window in a day. Use a TimePicker or a TimeRange.
- For exact entry alone. Handles are for finding a cut by eye. Pair them with a typed field when someone needs to enter a precise time.
- For a clip with no length to measure. Both components need a
durationin seconds.
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}
/>MediaTrimmer picks a range out of a clip — the filmstrip trim every video
editor ships. MediaSeekBar picks a point in one — the player scrub bar
with a preview bubble every streaming app ships.
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.
Not for a plain numeric range with no frames or playback position — use a Slider. Not for a coloured plate either: this is editor chrome, it has no on-color tone, and it paints page colours wherever it sits.
Examples
Default
A trim range with a playhead. Clicking the strip seeks; dragging a handle trims. The handles sit outside the selection, so the strip is inset by one handle's width on each side: at the clip bounds the handles stay inside the component.
Ruler and constraints
A longer clip with timecode ticks, and a selection held between 10 s and 3 min. In a narrow column the ruler draws fewer ticks rather than letting its labels collide. 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
Error
errorMessage explains why the selected range is invalid. It renders below the strip
in a polite live region mounted from first render, marks both trim handles
aria-invalid and describes them with the message — so a keyboard user moving a
handle hears why — and implies intent="error" for the colour. Drag the selection past
15 seconds here.
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.
RTL
The timeline does not mirror under dir="rtl". Time runs left to right, the
start handle stays on the left, and the arrow keys keep their physical meaning.
dir="rtl" on MediaTrimmer and MediaSeekBar sets the direction around the
track and defaults the names and the duration readout to Arabic. This example
also passes its own group name, seek name and readout wording, which win over
those defaults.
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", Arabic under dir="rtl" | Group name |
handleLabels | [string, string] | ["Trim start", "Trim end"], Arabic under dir="rtl" | Names for the two trim handles |
playheadLabel | string | "Playback position", Arabic under dir="rtl" | Name for the playhead |
dir | "ltr" | "rtl" | — | Sets the root's direction and picks the language of the names and the readout. The track stays left to right |
strings | Partial<MediaTrimmerStrings> | English, Arabic under dir="rtl" | Override the readout's selected ("{duration} selected") and the default names: trimMedia, trimStart / trimEnd and playbackPosition. label, handleLabels and playheadLabel win |
disabled | boolean | false | — |
readOnly | boolean | false | Focusable, not changeable |
errorMessage | ReactNode | — | Why the range is invalid. Announced politely, describes both trim handles and marks them aria-invalid; implies intent="error" unless another intent is set. Hidden while readOnly |
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", Arabic under dir="rtl" | Accessible name |
dir | "ltr" | "rtl" | — | Sets the root's direction and picks the language of the default name. The track stays left to right |
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.
Accessibility
VoiceOver + Safari: passed 2026-09-14. The root reads "Trim media, group"; handles read "0:04.2, Trim start, slider" and "0:22.8, Trim end, slider", each arrow press reads the new time, and the playhead reads "0:12.0, Playback position, slider". NVDA and JAWS are untested.
- 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. - An
errorMessageis never colour alone: both trim handles becomearia-invalidand are described by the message, and the message lands in a polite live region that exists before it does, so it is announced when it appears. The playhead is left alone — playback position is never the invalid input. - Keyboard: arrows step, Shift+arrow and PageUp/PageDown take the large step, Home/End jump to the clip bounds.
- Handles are 12px wide and the playhead 2px, because they have to sit on the cut, so each target is an invisible centred pad: 44×44 on a touch screen (WCAG 2.5.5) and 24×24 with a mouse (WCAG 2.5.8). The seek bar's target is a full-width band of the same heights, so the thumb meets the floor wherever it is. At the start or end of the clip a handle's touch pad reaches 16px past the component, so on a touch screen leave 16px between the trimmer and the edge of a scrolling container, or the page can scroll sideways.
- 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.