This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
Timeline Studio is a browser-based video editor with a multi-track timeline for visuals, captions, stickers, voiceover, source audio, music, and overlays.
The timeline is deliberately touch-first on mobile and precision-oriented on desktop. That distinction matters: mobile uses a fixed, centered playhead with equal leading and trailing gutters, while desktop uses a conventional scrollable timeline and a draggable playhead.
This bug fix started with a simple request: when a user trims a clip to the edge of the visible timeline, the timeline should continue scrolling—similar to CapCut—so they do not have to repeatedly stop, scroll, and resume trimming.
The feature worked quickly. The experience did not.
That gap between “technically correct” and “feels correct” became the real bug.
Bug Fix or Performance Improvement
The first implementation could extend a clip while auto-scrolling, but real use exposed a chain of UX failures:
- the handle accelerated when it approached the screen edge;
- reversing direction was much faster than extending;
- the ruler and clip drifted apart after zooming;
- releasing at 8 seconds could visually land near 4 seconds on mobile;
- a trimmed clip could disappear completely outside the viewport;
- a temporary scroll spacer could leave a large blank tail and a misleading scrollbar;
- fixes that felt right on desktop broke the mobile fixed-playhead model.
None of these were isolated CSS issues. They came from mixing multiple coordinate systems:
- pointer coordinates in the viewport;
- native horizontal scroll position;
- rendered timeline pixels;
- timeline time;
- mobile's canonical 480/520px track basis;
- desktop's actual viewport-width basis.
The visible symptom changed, but the underlying mistake was always the same: treating two different coordinate spaces as if they were interchangeable.
The UX invariants
Before changing more code, I wrote down the behavior the user should be able to trust:
- A trim handle must remain under the pointer or finger.
- One second must keep the same rendered pixel scale for the entire gesture.
- Edge scrolling may reveal more timeline, but it must not become a second trim delta.
- Ruler ticks, clips, and handles must use the same scale.
- Reversing direction should feel controlled, not amplified.
- A handle may approach an edge, but it must never disappear beyond it.
- Releasing a mobile gesture must preserve the mobile fixed-playhead position.
- Desktop and mobile can share math, but not assumptions about viewport geometry.
This reframed the work. Instead of tuning random speed constants, every change had to preserve an observable interaction invariant.
Code
The complete fix is available in PR #40.
1. Keep pointer and scroll displacement explicit
The time delta uses one stable content width and duration captured for the gesture:
export function getTimelineDragTimeDelta({
clientX,
startX,
scrollOffset = 0,
contentWidth,
timelineDuration,
}) {
if (contentWidth <= 0) return 0;
return ((clientX - startX + scrollOffset) / contentWidth) * timelineDuration;
}
The important detail is what goes into scrollOffset. Only deliberate edge-auto-scroll belongs there. Browser clamping and visual anchor correction do not. Feeding those values back into trim time created a positive feedback loop, which is why pulling back became faster than dragging out.
2. Make edge speed progressive and asymmetric
The interaction uses a 48px activation zone. Speed rises quadratically as the pointer approaches the edge:
const strength = clamp((threshold - distanceFromEdge) / threshold, 0, 1);
const step = Math.max(0.35, strength ** 2 * maxStep);
Forward extension can reach 14px per animation frame. Returning is capped at 6px per frame. This is not arbitrary polish: extending benefits from speed, while returning needs control because the user is already correcting an overshoot.
3. Lock pixels per second during the gesture
Project duration can grow while trimming. If the track remains width: 100%, every existing second is compressed into fewer pixels as the project grows. The handle then appears to lag behind the pointer.
During trimming, the track is rendered from a locked scale:
export function getTrimLockedTrackWidth(timelineDuration, pixelsPerSecond) {
return Math.max(0, timelineDuration) * Math.max(0, pixelsPerSecond);
}
The clip, ruler, and pointer conversion all read the same scale until release.
4. Respect the mobile scale basis
Mobile does not use the narrow visible track viewport as its scale basis. It uses a canonical 480px or 520px timeline width. Mixing those values produced the most dramatic bug: a release at 8 seconds could remap to roughly 4 seconds.
export function getTimelineVisibleDurationForPixelScale(
pixelsPerSecond,
scaleBasisWidth,
) {
return pixelsPerSecond > 0 ? scaleBasisWidth / pixelsPerSecond : 0;
}
Desktop continues to use its actual viewport basis. The shared formula remains shared; the correct input is platform-specific.
5. Never let a handle disappear
Raw pointer coordinates still drive edge auto-scroll, but time mapping uses a safe coordinate inside the viewport:
export function getTimelineTrimDragClientX(clientX, rect, inset = 10) {
const safeInset = Math.min(rect.width / 2, Math.max(0, inset));
return clamp(clientX, rect.left + safeInset, rect.right - safeInset);
}
This means dragging beyond the glass continues scrolling the timeline, while the visible handle stays reachable. The rule now applies to both desktop and mobile.
Mobile has one additional release rule: its fixed playhead cannot finish beyond the rendered project end.
export function getMobileTrimReleaseScrollLeft(scrollLeft, trackWidth) {
return trackWidth > 0 ? Math.min(Math.max(0, scrollLeft), trackWidth) : scrollLeft;
}
My Improvements
Fix the interaction, not the screenshot
Several intermediate fixes made one screenshot look correct while introducing a new interaction defect:
- retaining the maximum visible canvas prevented native scroll clamping, but created lag and a release flash;
- counter-scrolling by the shrink amount prevented a jump, but canceled the handle's movement;
- treating all scroll movement as trim displacement made reverse dragging accelerate;
- reusing desktop release behavior on mobile moved the fixed playhead after the finger lifted.
The useful question was never “How do we stop this element from jumping?” It was “Which coordinate is allowed to change, and which one must remain invariant for the user?”
Separate shared mechanics from platform policy
Desktop and mobile share:
- pointer-to-time conversion;
- progressive edge-scroll speed;
- stable pixels-per-second scaling;
- ruler synchronization;
- safe handle insets.
They intentionally differ in:
- the scale basis used when committing zoom;
- playhead behavior;
- leading and trailing gutters;
- release-time scroll constraints;
- temporary desktop scroll-space cleanup.
Responsive parity does not mean identical mechanics. It means both platforms preserve the same user promise using the geometry appropriate to each platform.
Turn UX observations into regression tests
The regression suite now tests concrete interaction outcomes, including:
- forward and reverse edge speed;
- stable drag-time scale;
- ruler labels in newly revealed regions;
- mobile 520px scale conversion (8 seconds remains 8 seconds);
- mobile release clamping versus unchanged desktop release behavior;
- desktop and mobile handle safe insets;
- cleanup of temporary desktop trailing scroll space.
The final focused suite contains 35 passing tests, alongside TypeScript checks and a production build.
What I learned
Timeline bugs are rarely just timing bugs. They are perception bugs caused by disagreement between coordinate systems.
A user does not care that the duration state is mathematically correct if the handle is 40 pixels away from their finger. They do not care that the scrollbar follows browser rules if the clip disappears after release. They experience one continuous physical gesture, so the implementation must behave like one continuous system.
The biggest improvement was not the 14px-per-frame auto-scroll. It was making every consumer—clip geometry, ruler geometry, scrolling, zoom commit, mobile release, and tests—agree on what the gesture means.
That is the difference between shipping a feature and shipping an interaction people can trust.
Top comments (0)