Back to artifacts
Jan 10, 20264 min read
Terminal Navigation Transitions
Building terminal-style route transitions in Next 16 without losing scroll or analytics fidelity.
motionfrontendreactnext
Why a terminal
The terminal metaphor is the closest thing to my everyday state, it signals that this site is a system, not just a gallery. I wanted navigation to feel like issuing a command, not clicking a link and watching a flash.
The goal: buy a beat so the motion reads, the command feels intentional, and the transition doesn't eat scroll position or analytics fidelity.
Motion language
| Phase | Duration | Easing | Purpose |
|---|---|---|---|
| Overlay in | 150ms | expo.out | Fast entry, immediate response to click |
| Command type | 300ms | linear | Typewriter effect, builds anticipation |
| Hold | 100ms | — | Let the eye settle before content swap |
| Overlay out | 400ms | expo.inOut | Slower exit, content arrival feels deliberate |
The asymmetry is intentional. Fast in, slow out. The user should never feel like they're waiting, but the content arrival should feel earned.
Implementation
The transition store
State machine that coordinates the overlay, the navigation, and the cleanup.
import { create } from 'zustand'
type TransitionState = 'idle' | 'entering' | 'holding' | 'exiting'
interface TransitionStore {
state: TransitionState
targetHref: string | null
command: string | null
startTransition: (href: string, navigate: () => void, command: string) => void
completeTransition: () => void
}
export const useTransitionStore = create<TransitionStore>((set, get) => ({
state: 'idle',
targetHref: null,
command: null,
startTransition: (href, navigate, command) => {
if (get().state !== 'idle') return
if (href === window.location.pathname) return // same route, skip
set({ state: 'entering', targetHref: href, command })
// Phase 1: Overlay enters (150ms)
setTimeout(() => {
set({ state: 'holding' })
// Phase 2: Navigate at peak
navigate()
// Phase 3: Hold then exit (100ms hold + 400ms exit)
setTimeout(() => set({ state: 'exiting' }), 100)
setTimeout(() => get().completeTransition(), 500)
}, 450) // 150ms enter + 300ms type
},
completeTransition: () => {
set({ state: 'idle', targetHref: null, command: null })
},
}))
The hook
Clean API for components. Returns a function that triggers the transition.
import { useRouter } from 'next/navigation'
import { useTransitionStore } from '@/store/transition-store'
export const useTerminalLink = () => {
const router = useRouter()
const startTransition = useTransitionStore((s) => s.startTransition)
return (href: string, command?: string) => {
const displayCommand = command || `cd ${href}`
startTransition(href, () => router.push(href), displayCommand)
}
}
The overlay component
GSAP handles the motion. The typewriter effect is a simple interval that reveals characters.
'use client'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { useRef, useState, useEffect } from 'react'
import { useTransitionStore } from '@/store/transition-store'
export function TransitionOverlay() {
const { state, command } = useTransitionStore()
const overlayRef = useRef<HTMLDivElement>(null)
const [displayedCommand, setDisplayedCommand] = useState('')
// Typewriter effect
useEffect(() => {
if (state !== 'entering' || !command) return
setDisplayedCommand('')
let i = 0
const interval = setInterval(() => {
if (i < command.length) {
setDisplayedCommand(command.slice(0, i + 1))
i++
} else {
clearInterval(interval)
}
}, 300 / command.length) // Distribute across 300ms
return () => clearInterval(interval)
}, [state, command])
useGSAP(
() => {
if (!overlayRef.current) return
if (state === 'entering') {
gsap.fromTo(
overlayRef.current,
{ opacity: 0 },
{ opacity: 1, duration: 0.15, ease: 'expo.out' },
)
} else if (state === 'exiting') {
gsap.to(overlayRef.current, {
opacity: 0,
duration: 0.4,
ease: 'expo.inOut',
})
}
},
{ dependencies: [state] },
)
if (state === 'idle') return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 bg-black/95 flex items-center justify-center'
>
<div className='font-mono text-white/80 text-sm'>
<span className='text-green-400'>→</span> {displayedCommand}
<span className='animate-pulse'>_</span>
</div>
</div>
)
}
Guardrails
Three edge cases that will break this if you don't handle them:
-
Same route click. Check
href === window.location.pathnamebefore starting. Otherwise you get an overlay that goes nowhere. -
Stalled transition. If navigation fails or takes too long, force complete after 2 seconds. Otherwise the user is stuck in overlay purgatory.
-
Hash links. In-page anchors should be instant. Check for
href.startsWith('#')and skip the transition entirely.
// In startTransition
if (href === window.location.pathname) return
if (href.startsWith('#')) {
navigate()
return
}
// Failsafe timeout
setTimeout(() => {
if (get().state !== 'idle') get().completeTransition()
}, 2000)
Next iteration
- Preload. Start fetching the next route during the overlay animation. The hold phase is dead time that could be doing work.
- Custom commands. Let sections register their own transition copy.
/workcould showls ./projects,/aboutcould showcat ./readme.md. - Reduced motion. Respect
prefers-reduced-motion. Skip the overlay, instant navigation.