Picture a sidebar collapsing — clean, smooth, on a 200ms easing curve. Then look closer. The icons jump a few pixels left when the rail narrows. The labels disappear into nothing. The “WORKSPACE” header vanishes when the rail collapses, and every row beneath it rises a half-step into the empty space. The animation is fine. The component is broken.
The thing nobody mentions about smoothness is that it's a layout problem in disguise. Every mount/unmount, every conditional render, every layout property that changes between states is a discontinuity that no easing curve can hide. You can attach the gentlest cubic bezier in the world to the rail's width — if the icon's screen position changes between the two states, the cubic bezier will animate it cleanly to the wrong place.
This post is about pulling those discontinuities out of a sidebar, one by one — and then about the second thing that turns out to matter: deciding whenthe user means to open the rail. There are five reasonable models for that decision, ranging from explicit click to predictive geometric triangulation, and the right one depends on the product more than the spec sheet would suggest. We'll meet all five.
Smoothness is geometry, not animation.
Where the jumps live
The rail starts at 232px wide when expanded. When collapsed, it shows a single column of icons. The first wrong move, almost reflexive, was making the collapsed rail wide enough to contain the icons comfortably (around 56px) and adding justify-content: center so the icons would look centered when the rail narrowed.
That centering rule was the jump. Adding justify-center only when collapsed means the icon's horizontal position changes between states. The width transition then animates the icon to its new position — a slow, smooth, visibleslide of about 6px. The cubic bezier didn't fail. It made a discontinuity legible.
The fix was geometry instead of CSS:
rail-width = 2 × (border + nav-pad-x + btn-pad-x + icon-half)
= 2 × (1 + 8 + 6 + 10)
= 50pxAt 50px wide, the icon's natural left-aligned position lands exactly on the rail's visual center. No justify-content rule. The icon stays at rail-x: 15 in both states. The rail width animates around the icon, not under it. Once the math is right, the easing curve has nothing to apologize for.
If centering an element requires moving it, you're solving the wrong problem.
The same principle then unspooled four more times, each smaller than the last, each surfacing a different way that “render only what's relevant to this state” creates a discontinuity that easing can't repair.
Render everything, hide selectively
Conditional rendering of structural elements is where the next jumps came from. The search row was wrapped in {expanded && (...)}, so it unmounted when the rail collapsed — that's why it disappeared. The “WORKSPACE” label did the same, which is why everything below it shifted up. Even the chevron was using data-[hidden=true]:hidden, which is just CSS-flavored unmount.
The rule that emerged: render every structural element in both states. Hide what shouldn't be visible with opacity and clipping (provided by the rail's overflow-hidden). Reserve {x && (...)} for genuinely conditional bits — the keyboard shortcut hint, for example, which is semantic ornamentation rather than structure.
For horizontal collapse of the labels themselves, the trick that does survive is grid-template-columns: 1fr ↔ 0fr. The label wrapper is a one-cell grid; transitioning the column track from 1fr to 0fr collapses the cell smoothly, the label fades out via opacity, and overflow-hidden on the rail clips the residue. No mount/unmount. The label keeps existing; the column it lives in disappears.
When collapse would shift the layout
The “WORKSPACE” label was the trickiest one. Its job is to introduce the second group of nav rows. When the rail collapses, the label has nowhere to go — the icons below it are already up against the section above. The first attempt used the same grid-template-rows: 1fr ↔ 0fr trick that worked horizontally. It worked perfectly for the label. It broke for the page. The label's height genuinely went to zero, which meant every row below it shifted up by 22px.
The right answer was to leave the slot in place and crossfade what's in it.
<div className="relative h-[22px]">
<span data-collapsed={!expanded}
className="absolute inset-0 ...
transition-opacity duration-200
data-[collapsed=true]:opacity-0">
Workspace
</span>
<span data-collapsed={!expanded}
className="absolute inset-x-2 top-1/2 h-px
bg-[rgba(255,255,255,0.06)]
transition-opacity duration-200
opacity-0 data-[collapsed=true]:opacity-100" />
</div>A 22px-tall container, fixed height. Inside it, two layers stacked absolutely: the “Workspace” text and a 1px hairline rule. They crossfade. The container never collapses; nothing below moves. The pattern generalises: when removing content would shift the surrounding layout, leave the space and crossfade what's inthe space. Don't collapse the container.
The five models of intent
With the rail behaving, the question becomes: how does the user open it? “Click a chevron” is one answer. It's also five answers when you start unpacking what the chevron is for.
v1 — Click
The simplest, the most explicit, the most accessible. A chevron in the rail's header. Click it; the rail toggles. Two clicks per session at most. Works on every input device. Costs nothing. The right answer when the rail is rarely toggled — say, a navigation rail where the user collapses once and forgets.
The whole component fits in five lines. The Rail is dumb; a single piece of state lifts it open or closed.
function RailClick() {
const [expanded, setExpanded] = useState(true);
return (
<Rail
expanded={expanded}
onHeaderClick={() => setExpanded((v) => !v)}
/>
);
}v2 — Dwell
Hover-to-expand is the hated antipattern — sidebars flying open every time the cursor reaches for a button on the left edge of the canvas. The fix isn't an animation curve; it's a state machine.
mouse enters → start expand timer (200ms)
mouse leaves before 200ms → cancel expand timer
expand timer fires → rail opens
mouse leaves open rail → start collapse timer (300ms)
mouse re-enters within 300ms → cancel collapse timerTwo refs (expandTimer, collapseTimer), each cleared on the inverse event. Fly-bys are absorbed by the expand delay. Cursor overshoots are absorbed by the collapse grace. The rail opens when the user means it and stays open while the user is still around.
The asymmetry between 200ms and 300ms encodes a posture. An unwanted expand interrupts the user; a slightly-late collapse doesn't. Make the model more cautious about opening than about closing. That's not just a number — that's a value judgment about whose convenience matters.
The implementation is two timer refs, four event paths, and a pair of clear-on-inverse-event guards. Drop it into any component that needs hover-with-intent:
const EXPAND_DELAY_MS = 200;
const COLLAPSE_DELAY_MS = 300;
function RailHover() {
const [expanded, setExpanded] = useState(false);
const expandTimer = useRef<number | null>(null);
const collapseTimer = useRef<number | null>(null);
const clear = (ref: { current: number | null }) => {
if (ref.current !== null) {
clearTimeout(ref.current);
ref.current = null;
}
};
const onEnter = () => {
clear(collapseTimer);
if (expanded || expandTimer.current !== null) return;
expandTimer.current = window.setTimeout(() => {
setExpanded(true);
expandTimer.current = null;
}, EXPAND_DELAY_MS);
};
const onLeave = () => {
clear(expandTimer);
if (!expanded || collapseTimer.current !== null) return;
collapseTimer.current = window.setTimeout(() => {
setExpanded(false);
collapseTimer.current = null;
}, COLLAPSE_DELAY_MS);
};
return (
<Rail
expanded={expanded}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
/>
);
}v3 — Velocity (drift gating)
Dwell alone has one failure mode: a slow-but-aimless cursor that happens to rest on the rail for 200ms triggers an expand the user didn't intend. The fix is to require the cursor to actually settle during the dwell window — not just be present, but be still.
The simplest version isn't velocity proper (which needs mousemove sampling and a derivative); it's drift. Capture the cursor's position when the expand timer starts. On every mousemove during the dwell window, check the squared distance from that origin. If it exceeds 28² pixels, kill the expand timer.
const MAX_DWELL_DRIFT_PX = 28;
// Capture the cursor when the expand timer starts:
const onEnter = (e: MouseEvent) => {
// ...existing dwell scheduling...
dwellStart.current = { x: e.clientX, y: e.clientY };
};
// Run the drift check on every move during the dwell window.
// Squared distance avoids the sqrt — cheap, runs at native event rate.
const onMove = (e: MouseEvent) => {
if (!dwellStart.current) return;
if (expandTimer.current === null) return;
const dx = e.clientX - dwellStart.current.x;
const dy = e.clientY - dwellStart.current.y;
if (dx * dx + dy * dy > MAX_DWELL_DRIFT_PX ** 2) {
clearTimeout(expandTimer.current);
expandTimer.current = null;
dwellStart.current = null;
}
};One branch per frame, no sample buffer. Cursors that pause and stay get the rail. Cursors that drift past don't.
v4 — Trajectory (intent triangulation)
The Amazon mega-menu trick, mirrored for a sidebar (originally documented by Ben Kamens for the dropdown case). Instead of waiting for the cursor to arrive at the rail, predictarrival from a 60px approach zone outside the rail's right edge. As the cursor crosses into that zone, capture its position as the apex of a triangle whose other two vertices are the rail's top-right and bottom-right corners. While the cursor stays inside that triangle, it's still “heading to the rail.” Veer outside the triangle, and the intent is dropped.
That algorithm took three iterations to settle. Each one shipped because the prior version felt wrong under the cursor, even when the prose said it should work. Walking through what each gate fixes is the most useful way to read the final code.
Iteration one: triangle alone.Capture the apex at the first sample inside the zone, run the point-in-triangle test on every subsequent sample, expand on the first sample that's inside the triangle. This is what the Amazon mega-menu does for a submenu, and on paper it's perfect. In practice it fires on the very next sample after the apex is captured — the cursor has barely moved 33 ms-worth, so it's trivially inside the freshly-drawn triangle. The rail opens the moment you enter the zone. Triangulation, but no triangulation. A presence check wearing the costume of a direction check.
Iteration two: triangle + cumulative closing distance. Add a second gate: the cursor must close at least somedistance toward the rail (apex.x − cursor.x) while staying in the triangle. The rail opens when the gate is met. Better. But cumulative closing is path-blind — a slow lazy drift inside the zone eventually accumulates 20 px and triggers the open, even though the user is just hovering. The geometry doesn't know whether the closing happened in one decisive sweep or a minute-long meander.
Iteration three: triangle + cumulative closing + per-sample floor with a re-anchoring apex.Add a third gate: each sample's closing — not the cumulative number, the delta from the previous sample — must be at least a couple of pixels. If a sample fails that floor (the cursor stalled, drifted sideways, or reversed), the apex re-anchors to the cursor's current position and the cumulative counter resets to zero. The triangle redraws from the new apex, so the predictive corridor follows the user. The cumulative counter only fills under continuous, committed forward motion. Once it hits 25 px, the rail opens.
type ApexState = { x: number; y: number; lastX: number };
const APPROACH_ZONE_PX = 60;
const MIN_APPROACH_DELTA_PX = 25;
const MIN_SAMPLE_CLOSING_PX = 2;
// Inside the throttled (~30Hz) document-level pointermove handler:
if (insideRail) {
// Cursor reached the rail — drop the apex, expand if not already.
triangleApex.current = null;
if (!expanded) setExpanded(true);
return;
}
if (!inApproachZone) {
// Outside both zone and rail — drop the apex, schedule collapse.
triangleApex.current = null;
if (expanded) scheduleCollapse();
return;
}
// Inside the approach zone, outside the rail.
if (!triangleApex.current) {
// First sample in the zone — capture the apex.
triangleApex.current = { x: cursor.x, y: cursor.y, lastX: cursor.x };
return;
}
const apex = triangleApex.current;
const sampleClosing = apex.lastX - cursor.x; // positive = forward
apex.lastX = cursor.x;
if (sampleClosing < MIN_SAMPLE_CLOSING_PX) {
// Stall, drift sideways, or reverse — re-anchor the apex.
apex.x = cursor.x;
apex.y = cursor.y;
return;
}
// Genuine forward motion. Run the geometry tests.
const closingDistance = apex.x - cursor.x;
const inTriangle = pointInTriangle(
cursor,
apex,
{ x: rail.right, y: rail.top },
{ x: rail.right, y: rail.bottom },
);
if (!inTriangle) {
triangleApex.current = null; // veered out of the corridor
return;
}
if (closingDistance >= MIN_APPROACH_DELTA_PX) {
setExpanded(true); // intent confirmed: open predictively
}The three gates prove three different things at once: direction-of-aim (triangle), direction-of-motion (closing distance), and commitment-of-motion (no stalls). Any one alone is a feature. All three together are intent.
The shape of intent isn't a single condition — it's an intersection.
The numbers earn their place by simple arithmetic. At a 30 Hz sample rate, a 2 px-per-sample floor rejects motion slower than 60 px/sec — the floor between idle hover drift and committed motion. 25 px of cumulative forward motion takes ~125 ms at a typical 200 px/sec sweep — fast enough that the rail opens before the cursor reaches it, slow enough to read as deliberate. The rail's width animates for 300 ms after the trigger, so the cursor catches up with a rail that's already mid-open.
The geometry under all three gates is one barycentric point-in-triangle test, sign-of-cross-products, plus two subtractions. The listener is a global pointermove throttled to ~30 Hz. This is the highest-fidelity intent model in the lab and the most code — worth the cost only when the rail is hot enough to justify the listener load. IDE-class density. Not lighter products.
v5 — Pinned
For when the user wants out of the dance entirely. A small pin button in the rail's header. Click to lock; click to release. Underneath, the dwell-hover machinery keeps running. Two state machines run in parallel, OR'd together. When the user unpins, the rail's behaviour depends on where the cursor is — if it's still over the rail, the rail stays open (because hoverExpanded is already true); if it's not, the rail collapses smoothly via the existing 300ms grace. No special “what state were we in when we pinned” memory needed. The two machines are fully orthogonal.
function RailPinned() {
const [pinned, setPinned] = useState(false);
const [hoverExpanded, setHoverExpanded] = useState(false);
const expanded = pinned || hoverExpanded;
// ...same dwell timers as RailHover, but flipping hoverExpanded
// (not expanded) — pin is independent.
const pinButton = (
<button
type="button"
onClick={() => setPinned((p) => !p)}
aria-pressed={pinned}
aria-label={pinned ? "Unpin menu" : "Pin menu open"}
>
{pinned ? <PinSolid /> : <Pin />}
</button>
);
return (
<Rail
expanded={expanded}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
headerExtra={pinButton}
/>
);
}The pin button is the only place in the rail where the brand indigo appears — on the pinned-and-active state. Restraint scales: an accent that only shows up when the user has explicitly chosen to lock something open does more than five accents scattered as decoration.
Touch
The fifth model isn't a fifth model — it's an admission. Hover doesn't exist on touch. A (pointer: coarse) media query falls hover-driven variants back to v1 (click) at the switcher level, with a small note explaining the swap. The variants themselves don't know about touch. Cross-cutting concerns belong on the seam, not in every component.
A key prop forces a fresh mount when the fallback engages, so any half-set timers from the unmounted variant don't leak.
The pattern that survives
Five variants. Each is roughly 60–110 lines. Together they share about 95% of their visual surface — the rail itself. The boundary between what the rail looks like and how the rail decides to be expanded was worth every minute spent finding it.
collapsible-side-menu/
config.ts — shared types and row data
NavRow.tsx — shared row component
Rail.tsx — shared visual rail (controlled, dumb)
RailClick.tsx — v1: click toggle
RailHover.tsx — v2: dwell + grace
RailVelocity.tsx — v3: dwell + drift gating
RailTrajectory.tsx — v4: intent triangulation
RailPinned.tsx — v5: dwell + pin escape hatch
useIsTouchDevice.tsThe visual is the asset. The state machines are interchangeable. Adding a sixth variant — focus-driven, scroll-anchored, command-K, whatever — is one file. Never a fork of the visual.
Making the state machine visible
Trajectory, especially, is a model that lives entirely inside the developer's head. The triangle is real — the rail's open/closed behaviour depends on it — but the user only ever sees the side effects. When the prediction is right, it feels like magic. When it's wrong, it feels like a bug. Both are illegible from the outside.
So I built a debug overlay. Press D (or the small bug-icon pill in the bottom-right corner of the lab page). The instrumented variants — v2, v3, v4 — each render their state alongside the rail:
- Dwell shows a HUD with the expand and collapse delays and the current phase (
idle,dwell,expanded,closing). - Velocity draws a cyan dashed circle at the dwell origin showing the 28px drift threshold, with a live readout of the current drift in N px / 28 px form. When drift kills the expand, the phase reads
killed. - Trajectorydraws the whole geometry — rail outline, 60px approach zone, the triangle from apex to the rail's right edge, the apex dot, a hollow circle tracking the cursor — plus a HUD with sample rate, in-approach and in-triangle flags, and apex coordinates.
The overlay sits in fixed-positioned SVG over the viewport, pointer-events-none, in a cyan palette that's deliberately distinct from the rail's brand indigo. The toggle pill matches the variant switcher's aesthetic so the diagnostic chrome reads as part of the lab, not a third-party widget pasted on top.
The cost was small — a shared DebugHUD component, a debug prop on the instrumented variants, a few extra useState hooks for the snapshot, around 200 lines in total. The benefit isn't just developer ergonomics. With the overlay on, the triangle stops being a footnote in a comment block and starts being the star of the demo. The rail's behaviour stops being magic and starts being legible geometry.
And the overlay paid for itself the first afternoon it was on. Watching v4 with the triangle drawn, I noticed the rail opening the instant the cursor crossed into the approach zone — regardless of where it was heading. The closing- distance gate above came out of that observation. The triangle test alone had been a presence check the whole time, and no amount of staring at the state machine in code would have shown that. The overlay made the lie obvious in the first sweep.
The state machine you can see is the state machine you can defend.
This generalises beyond sidebars. Any interaction whose correctness depends on hidden geometry — a swipe threshold, a snap zone, a force vector, a hit slop, a focus trap — is a candidate for a visualiser. Build it even if it never ships. The visualiser tells you whether your math is right. The visualiser tells you whether your thresholds match your intuition. And once the math is right, the visualiser becomes the documentation.
Picking among them
There isn't a winner. There's a fit.
Click is best when the rail is rarely toggled. Dwell is best for casual sidebar-as-context. Velocity earns its keep when fly-bys are common — mouse-heavy apps, broad canvases. Trajectory is for the kind of density where every millisecond of perceived response counts. Pinned is for users who want to stop participating in the cursor dance entirely.
The right pick is rarely the most accurate one. Cost matters. So does what the user is doing the rest of the time, and how often the rail is the thing they're trying to use. The honest answer for most products is dwell. The honest answer for some products is click. The honest answer for an IDE-class workspace is trajectory. The honest answer for a tool where the user lives in the sidebar all day is pinned-by-default.
What's not in the box
Five threads still open in the lab notebook, none of them required for the experiment to be coherent. prefers-reduced-motion should drop the rail's width transition and label fades to zero duration. The active row should keep a subtle background tint when the rail collapses, so the user can still see where they are. localStorage for the variant choice and the pinned state would survive reloads. v5 on touch could drop the hover machinery and become a tap-to-pin variant, since pinning makes more sense on touch than hover does. And trajectory false positives — fast diagonal cursors that stay in the triangle without actually heading to the rail — would refine with a direction check on top of the geometry.
Each is a small piece. Together they're the receipt for thinking the rail is “done” being a thing worth paying attention to.
Coda
The component finished at around 220 lines for the rail, roughly the same again for the five variants combined, and one shared notion of what the rail looks like. Every discontinuity removed left the file shorter, not longer — once you stop conditionally rendering, you stop needing the conditions. Restraint shows up in the diff.
The unified lesson, in two halves. Geometry handles visual continuity; intent modeling handles behavioural continuity. Both are about making the rail's behaviour continuous with the user's mental model — not slow, not careful, but continuous, the way a door that opens because you reached for it is continuous with the reach.
You can drive all five variants at /lab/collapsible-side-menu.