Video Trimmer
Trim a clip: drag the in and out handles over a frame strip, scrub the playhead, and read the new duration as you go.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/video-trimmer.json'use client'
import { useEffect, useRef, useState } from 'react'
import './video-trimmer.css'
export interface VideoTrimmerProps {
duration: number
defaultRange?: [number, number]
min?: number
strip?: 'frames' | 'wave'
onChange?: (r: [number, number]) => void
fill?: 'dark' | 'light'
className?: string
}
const t = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`
/** Clip trimmer with in/out handles, slide and preview play. */
export function VideoTrimmer({ duration, defaultRange = [duration * 0.2, duration * 0.7], min = 1, strip = 'frames', onChange, fill = 'dark', className }: VideoTrimmerProps) {
const [r, setR] = useState<[number, number]>(defaultRange)
const [ph, setPh] = useState(defaultRange[0])
const [play, setPlay] = useState(false)
const box = useRef<HTMLDivElement>(null)
const drag = useRef<{ k: 'a' | 'b' | 'm'; x: number; r: [number, number] } | null>(null)
const commit = (n: [number, number]) => { const a = Math.max(0, Math.min(n[0], duration - min)), b = Math.min(duration, Math.max(n[1], a + min)); setR([a, b]); setPh((p) => Math.min(b, Math.max(a, p))); onChange?.([a, b]) }
useEffect(() => {
if (!play) return
let raf = 0, last = performance.now()
const step = (now: number) => { const dt = (now - last) / 1000; last = now; setPh((p) => { const n = p + dt; if (n >= r[1]) { setPlay(false); return r[1] } return n }); raf = requestAnimationFrame(step) }
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [play, r])
const sec = (dx: number) => (dx / (box.current?.offsetWidth || 1)) * duration
const down = (k: 'a' | 'b' | 'm') => (e: React.PointerEvent) => { e.stopPropagation(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); drag.current = { k, x: e.clientX, r }; setPlay(false) }
const move = (e: React.PointerEvent) => {
const d = drag.current; if (!d) return
const s = sec(e.clientX - d.x)
if (d.k === 'a') commit([Math.min(d.r[0] + s, d.r[1] - min), d.r[1]])
else if (d.k === 'b') commit([d.r[0], Math.max(d.r[1] + s, d.r[0] + min)])
else { const len = d.r[1] - d.r[0]; const a = Math.max(0, Math.min(duration - len, d.r[0] + s)); commit([a, a + len]); setPh(a) }
}
const key = (k: 'a' | 'b') => (e: React.KeyboardEvent) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
if (d) { e.preventDefault(); const s = d * (e.shiftKey ? 1 : 0.1); commit(k === 'a' ? [Math.min(r[0] + s, r[1] - min), r[1]] : [r[0], Math.max(r[1] + s, r[0] + min)]) }
if (e.key === ' ') { e.preventDefault(); toggle() }
}
const toggle = () => { if (!play && ph >= r[1] - 0.05) setPh(r[0]); setPlay(!play) }
const p = (s: number) => `${(s / duration) * 100}%`
return (
<div className={['sd-vtr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-vtr__pv" aria-hidden>{t(ph)}<i style={{ width: `${((ph - r[0]) / (r[1] - r[0])) * 100}%` }} /></div>
<div ref={box} className="sd-vtr__strip" onPointerMove={move} onPointerUp={() => (drag.current = null)}>
<div className="sd-vtr__fr" data-k={strip} aria-hidden>
{Array.from({ length: strip === 'wave' ? 40 : 9 }, (_, i) => <span key={i} style={strip === 'wave' ? { height: `${20 + Math.abs(Math.sin(i * 1.7) * 60 + Math.sin(i * 0.6) * 20)}%` } : undefined} />)}
</div>
<span className="sd-vtr__dim" style={{ left: 0, width: p(r[0]), borderRadius: '10px 0 0 10px' }} />
<span className="sd-vtr__dim" style={{ left: p(r[1]), right: 0, borderRadius: '0 10px 10px 0' }} />
<span className="sd-vtr__sel" style={{ left: p(r[0]), width: p(r[1] - r[0]) }} onPointerDown={down('m')} />
<span className="sd-vtr__h" data-s="a" role="slider" tabIndex={0} aria-label="Start" aria-valuemin={0} aria-valuemax={duration} aria-valuenow={Number(r[0].toFixed(1))} aria-valuetext={t(r[0])}
style={{ left: p(r[0]) }} onPointerDown={down('a')} onKeyDown={key('a')} />
<span className="sd-vtr__h" data-s="b" role="slider" tabIndex={0} aria-label="End" aria-valuemin={0} aria-valuemax={duration} aria-valuenow={Number(r[1].toFixed(1))} aria-valuetext={t(r[1])}
style={{ left: p(r[1]) }} onPointerDown={down('b')} onKeyDown={key('b')} />
<span className="sd-vtr__ph" style={{ left: p(ph) }} />
</div>
<div className="sd-vtr__bar">
<button type="button" className="sd-vtr__play" aria-label={play ? 'Pause' : 'Play selection'} aria-pressed={play} onClick={toggle}>
<svg viewBox="0 0 12 12" aria-hidden>{play ? <path d="M2.5 1.5h2.5v9H2.5zM7 1.5h2.5v9H7z" /> : <path d="M3 1.5v9l7.5-4.5z" />}</svg>
</button>
<span>{t(r[0])} – {t(r[1])}</span>
<b aria-live="polite">{(r[1] - r[0]).toFixed(1)}s</b>
</div>
</div>
)
}.sd-vtr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-vtr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-vtr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vtr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vtr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vtr { width: min(100%, 340px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-vtr__pv { position: relative; display: grid; place-items: center; aspect-ratio: 16 / 9; margin-bottom: 14px; border-radius: 12px; background: var(--soft); overflow: hidden; font: 600 26px/1 ui-sans-serif, system-ui; font-variant-numeric: tabular-nums; color: var(--mute); }
.sd-vtr__pv i { position: absolute; left: 0; bottom: 0; height: 3px; background: var(--fg); }
.sd-vtr__strip { position: relative; height: 52px; border-radius: 10px; touch-action: none; user-select: none; }
.sd-vtr__fr { position: absolute; inset: 0; display: flex; gap: 2px; border-radius: 10px; overflow: hidden; }
.sd-vtr__fr span { flex: 1; background: var(--soft); }
.sd-vtr__fr span:nth-child(3n) { background: var(--line); }
.sd-vtr__fr[data-k='wave'] { align-items: center; gap: 2px; padding: 0 6px; background: var(--soft); }
.sd-vtr__fr[data-k='wave'] span { flex: 1; border-radius: 2px; background: var(--mute); }
.sd-vtr__dim { position: absolute; top: 0; bottom: 0; background: color-mix(in srgb, var(--bg) 72%, transparent); }
.sd-vtr__sel { position: absolute; top: -2px; bottom: -2px; border-radius: 10px; box-shadow: inset 0 0 0 2.5px var(--fg); cursor: grab; }
.sd-vtr__h { position: absolute; top: -2px; bottom: -2px; width: 14px; display: grid; place-items: center; background: var(--fg); cursor: ew-resize; outline: 0; }
.sd-vtr__h::after { content: ''; width: 2px; height: 16px; border-radius: 2px; background: var(--bg); }
.sd-vtr__h[data-s='a'] { border-radius: 10px 0 0 10px; transform: translateX(-100%); }
.sd-vtr__h[data-s='b'] { border-radius: 0 10px 10px 0; }
.sd-vtr__h:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--fg); }
.sd-vtr__ph { position: absolute; top: -6px; bottom: -6px; width: 2px; margin-left: -1px; border-radius: 2px; background: var(--fg); pointer-events: none; }
.sd-vtr__bar { display: flex; align-items: center; gap: 10px; margin-top: 14px; font-size: 12.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-vtr__play { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; }
.sd-vtr__play svg { width: 13px; height: 13px; fill: currentColor; }
.sd-vtr__bar b { margin-left: auto; color: var(--fg); font-size: 14px; }
@media (prefers-reduced-motion: reduce) { .sd-vtr *, .sd-vtr *::before, .sd-vtr *::after { animation: none !important; transition: none !important; } }Overview
The strip shows frames across the clip. Drag either handle to trim; the region outside dims and the duration updates. Drag inside the selection to slide it.
Play runs the playhead through the trimmed range only. Handles respect a minimum length.
When to use
- Video uploads, voice notes, GIF makers.
When not to use
- Multi-track editing.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/video-trimmer.json$
npx shadcn@latest add https://sundaeui.dev/r/video-trimmer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/video-trimmer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/video-trimmer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy video-trimmer.tsx and video-trimmer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { VideoTrimmer } from '@/components/sundae/video-trimmer'
<VideoTrimmer />Props
| Prop | Type | Default |
|---|---|---|
duration*Clip length, seconds. | number | — |
defaultRangeStart and end, seconds. | [number, number] | — |
minMinimum clip length. | number | 1 |
onChangeCalled on change. | (range: [number, number]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Both handles are sliders with time values; arrows nudge by 0.1s, Shift by 1s.
- Play is a toggle button; the duration is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vtr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Nudge handle 0.1s
- Shift + ← / →
- Nudge 1s
- Space
- Play / pause
FAQ
- Is Video Trimmer free?
- Yes. Video Trimmer is free for personal and commercial projects, with full source on this page.
- How do I install Video Trimmer?
- Run npx shadcn@latest add https://sundaeui.dev/r/video-trimmer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Video Trimmer support keyboard and screen readers?
- Both handles are sliders with time values; arrows nudge by 0.1s, Shift by 1s. Play is a toggle button; the duration is announced.
- Does Video Trimmer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Order Tracker
Components · RefinedImage Cropper
Components · RefinedSplit Pane
Components · RefinedIncident Timeline
Components · Refined- Pro
Reasoning Trace
Components · Refined Toast Stack
Components · Refined