Smooth Terminal Navigation Drawer Motion
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.tsxwith a small phase model:closed,opening,open, andclosing. - 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 inapps/web/app/globals.cssfor 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-motionis 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.
Important Implementation Details
- The trigger now derives its
aria-expandedstate 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
openingandclosingphases, 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-3byby 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.