Smooth Terminal Navigation Drawer Motion

Created: 2026-05-23 20:02 EDT · Repo: islandflow · Branch: sidebar-redesign

Beads: islandflow-cwr Follow-up: islandflow-3by Scope: terminal shell motion polish

Summary

The terminal navigation drawer now enters and exits with a short, controlled slide-and-fade instead of appearing and disappearing abruptly. The interaction stays aligned with the product’s calm, task-first character and still respects reduced-motion preferences.

Changes Made

  • Replaced the boolean-only drawer mount logic in apps/web/app/terminal.tsx with a small phase model: closed, opening, open, and closing.
  • Kept the drawer mounted briefly during close so the exit animation can complete before the DOM node is removed.
  • Added data-state-driven motion styles in apps/web/app/globals.css for the drawer surface and the backdrop.
  • Used a fast exponential ease with separate enter and exit timings to make the drawer feel decisive without being harsh.
  • Made the motion path reduced-motion-safe by disabling transitions in CSS and skipping delayed unmount logic in JavaScript when prefers-reduced-motion is enabled.

Context

Islandflow’s product context calls for composure under volatility, precise feedback, and minimal ornamental motion. The previous drawer behavior broke that by popping in and out instantly, which felt more like a state glitch than an intentional navigation reveal.

The goal here was not to make the drawer dramatic. It was to make it legible: a short spatial transition that confirms where the panel comes from and where it goes.

Important Implementation Details

  • The trigger now derives its aria-expanded state from the drawer phase, so assistive state follows the visible interaction rather than a raw mount boolean.
  • Escape, backdrop click, and the close button all use the same close path, which means every dismissal route gets the same exit animation behavior.
  • The route-change effect still hard-resets the drawer to closed, which keeps navigation swaps predictable and avoids carrying a half-finished overlay across pages.
  • The close phase disables pointer interaction, preventing the backdrop or drawer from intercepting extra clicks while the exit motion finishes.

Relevant Diff Snippets

apps/web/app/terminal.tsx · phase-based drawer lifecycle

- const [drawerOpen, setDrawerOpen] = useState(false);
- useEffect(() => {
-   setDrawerOpen(false);
- }, [pathname]);
+ type NavDrawerPhase = "closed" | "opening" | "open" | "closing";
+ const [drawerPhase, setDrawerPhase] = useState<NavDrawerPhase>("closed");
+ const drawerExpanded = drawerPhase === "opening" || drawerPhase === "open";
+ const openNavDrawer = useCallback(() => {
+   if (prefersReducedMotion()) {
+     setDrawerPhase("open");
+     return;
+   }
+   setDrawerPhase("opening");
+   drawerFrameRef.current = window.requestAnimationFrame(() => {
+     setDrawerPhase("open");
+   });
+ }, []);
+ const closeNavDrawer = useCallback(() => {
+   if (prefersReducedMotion()) {
+     setDrawerPhase("closed");
+     return;
+   }
+   setDrawerPhase("closing");
+   drawerCloseTimerRef.current = window.setTimeout(() => {
+     setDrawerPhase("closed");
+   }, NAV_DRAWER_EXIT_MS);
+ }, [drawerPhase]);

apps/web/app/globals.css · drawer and backdrop motion states

+ --drawer-enter-duration: 220ms;
+ --drawer-exit-duration: 180ms;
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);

+ .terminal-nav-drawer {
+   opacity: 0;
+   transform: translate3d(calc(-1 * min(22px, 6vw)), 0, 0);
+   transition:
+     transform var(--drawer-enter-duration) var(--ease-out-expo),
+     opacity var(--drawer-enter-duration) var(--ease-out-expo);
+ }
+ .terminal-nav-drawer[data-state="open"] {
+   opacity: 1;
+   transform: translate3d(0, 0, 0);
+ }
+ .terminal-nav-drawer[data-state="closing"] {
+   transition-duration: var(--drawer-exit-duration), var(--drawer-exit-duration), var(--drawer-exit-duration);
+   pointer-events: none;
+ }
+ .terminal-drawer-backdrop[data-state="open"] {
+   opacity: 1;
+ }
+ @media (prefers-reduced-motion: reduce) {
+   .terminal-nav-drawer,
+   .terminal-drawer-backdrop {
+     transition: none;
+   }
+ }

Snippets are rendered client-side with Diffs (diffs.com project) and include inline fallback text for offline viewing.

Expected Impact for End-Users

Users opening the navigation drawer should now see a calmer reveal that feels attached to the shell instead of pasted over it. Closing the drawer should feel cleaner as well, especially on mobile-width layouts where the abrupt unmount was most noticeable.

Validation

  • Passed: bun --cwd=apps/web run build
  • Verified in code: the drawer now has explicit opening and closing phases, shared dismissal handlers, and reduced-motion branching.
  • Limited: a live browser animation pass was attempted against http://localhost:3000, but the dedicated in-app browser tooling was not callable in this session and transient Playwright access was unavailable without extra setup.

Issues, Limitations, and Mitigations

  • This change improves the shell animation itself, but it does not yet add DOM-level interaction tests for the drawer lifecycle.
  • The route-change dismissal still resets immediately instead of animating closed, which is intentional to avoid overlay state bleeding into the next screen.
  • The motion tuning is scoped to the navigation drawer only, so any future shell overlays may still need their own consistency pass.

Follow-up Work

  • Complete islandflow-3by by adding interaction coverage for open, Escape dismiss, backdrop dismiss, and route-change dismiss behavior.
  • Consider harmonizing other shell overlays, such as administrative or evidence drawers, around the same motion timing tokens if they begin to feel inconsistent.