visar.log
Technical notes from building things
← all posts

Peek Swipe: TikTok-Style Navigation in a Userscript

Before

The tango userscript had swipe navigation between streams — swipe up for next, down for previous. But it was a flick: cross an 80px threshold, release, and the next stream appears instantly. No preview, no visual feedback during the drag. It worked, but it felt like clicking a button with your thumb.

The video-platform project (a local Svelte app) already had the TikTok-style peek gesture. Drag up and the next video slides in from below, tracking your finger. Release past 20% of the screen and it commits. Release before that and it snaps back. It made navigation feel physical.

Both projects use the same 3-unit carousel pattern. Three stream units exist in the DOM: units[0] (previous), units[1] (active), units[2] (next). When the user navigates forward, the units rotate — [0,1,2] becomes [1,2,0] — and the freed unit gets recycled with new content. Only units[1] is visible at any time.

This rotation is what makes peek possible. The next and previous units already have their streams loaded. They’re just hidden. Peeking is a matter of unhiding one and applying the right translateY.

Peek Update: Following the Finger

During the drag, navPeekUpdate(dy) is called on every touchmove. The math is straightforward:

navPeekUpdate(dy: number): void {
    const vh = window.innerHeight;
    const active = this.units[1];
    const peekUnit = dy < 0 ? this.units[2] : this.units[0];
    const hideUnit = dy < 0 ? this.units[0] : this.units[2];

    hideUnit.element.hidden = true;
    peekUnit.element.hidden = false;

    active.element.style.transition = 'none';
    peekUnit.element.style.transition = 'none';

    active.element.style.transform = `translateY(${dy}px)`;
    peekUnit.element.style.transform = dy < 0
        ? `translateY(${dy + vh}px)`
        : `translateY(${dy - vh}px)`;
}

dy < 0 means dragging up (next). The peek unit starts one full viewport height below (dy + vh), so when dy is 0, it’s just off-screen. As the finger moves up, both the active and peek units slide together. transition: none prevents any CSS animation from interfering — the transforms track the finger directly.

The other non-active unit is explicitly hidden. Without this, dragging up would also show the previous unit peeking from above.

The Commit/Cancel Decision

On touchend, navPeekRelease checks two conditions: the drag exceeded 20% of the viewport height, and the peek unit actually has content loaded. If both pass, commit. Otherwise, cancel.

const commit = Math.abs(dy) > vh * this.NAV_COMMIT_THRESHOLD
    && peekUnit.hasContent;

The hasContent check prevents committing to an empty unit. If there’s no next stream (end of list), the user sees the empty unit slide partway in during the drag, but releasing always cancels.

Both commit and cancel animate with transform ${250}ms ease-out. Commit slides the active unit off-screen and the peek unit to center. Cancel slides both back to their starting positions.

The Handoff Problem

This was the tricky part. The existing navigation flow works through events:

UI.NEXT → AppState.next() → STATE_CHANGED → VideoManager.onStateChanged()
  → _handleNextNavigation() → rotates units, sets visibility, updates recycled unit

_handleNextNavigation rotates the array, hides the old active unit, shows the new one, and loads content into the recycled unit. But after a peek commit, the peek unit is already visible and at translateY(0). If _handleNextNavigation hides and shows units in its normal flow, there’s a flash — the peek unit briefly disappears and reappears.

The solution: after the commit animation finishes, clear all transforms and transitions but do NOT touch visibility. Then emit Events.UI.NEXT. The navigation handler runs, rotates the array, and sets visibility as normal. Since the peek unit was already visible, setHidden(false) on an already-visible element is a no-op. No flash.

const onEnd = () => {
    this._clearPeekStyles();
    this.emitter.emit(Events.UI.NEXT);
    onDone();
};
active.element.addEventListener('transitionend', onEnd, { once: true });

_clearPeekStyles clears transforms and transitions on all three units, then hides non-active units. This is safe because by the time the navigation handler runs in the same call stack, it will set the correct visibility state — the peek unit (now units[1] after rotation) stays visible.

Clipping the Overflow

One visual issue: during the drag, the peek unit sliding in from below was visible outside the video container’s bounds. Adding overflow: hidden to #videoContainer clips it cleanly. The peek unit appears to slide in from the edge of the screen rather than floating on top.

Threading Through the Architecture

The peek callbacks needed to flow from VideoManager (which owns the units array and knows the layout) through StreamUnit (which creates the gesture controller) to GestureController (which detects the gestures):

VideoManager → creates PeekCallbacks → passes to StreamUnit constructor
StreamUnit → stores callbacks → passes to GestureController constructor
GestureController → calls callbacks on touchmove/touchend/touchcancel

The GestureController doesn’t know about units, transforms, or navigation. It just reports dy values. The VideoManager doesn’t know about touch events. Clean separation — the same pattern the edge-back swipe already uses, just with a different callback interface.

Takeaway

The peek gesture turns a binary action (navigate or don’t) into a continuous one (preview how far you’ll go). The visual polish comes from one insight: the next stream is already loaded and in the DOM. Making it visible and applying a transform is nearly free. The hard part isn’t the animation — it’s the handoff between “the user is dragging” and “the system takes over navigation,” which requires careful ordering of style cleanup and event emission to avoid visual artifacts.