function
<Component />
return
const
interface
{ }
color.primary
TS
() => {}
spacing.xl
dev
tokens
[ ]
code
<T>
type
async
font.sans
JS
TypeScript
export
[React, ...skills]
components
import
border.radius
props
default
?.
tokens
shadow.lg
await
&&
class
<Props>
accessibility
variables
transition
JSX
</>
theme
motion
( )
hooks
state
utils
wcag
responsive
grid
flex
scale
{ml}
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

PhaseDurationEasingPurpose
Overlay in150msexpo.outFast entry, immediate response to click
Command type300mslinearTypewriter effect, builds anticipation
Hold100msLet the eye settle before content swap
Overlay out400msexpo.inOutSlower 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:
  1. Same route click. Check href === window.location.pathname before starting. Otherwise you get an overlay that goes nowhere.
  2. Stalled transition. If navigation fails or takes too long, force complete after 2 seconds. Otherwise the user is stuck in overlay purgatory.
  3. 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. /work could show ls ./projects, /about could show cat ./readme.md.
  • Reduced motion. Respect prefers-reduced-motion. Skip the overlay, instant navigation.
© 2026 Mark Learst.Crafted with precision
privacy
v2026.1.0