Runway LoaderAn airplane taxis down a runway progress bar, nose tilting up and taking off at 100%.
WidgetsFramer MotionAnimation
Switch to light
Refresh
Full screen
0%
Preparing for takeoff'use client'
// npm install framer-motion
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
// ─── Airplane SVG — top-down view, pointing right ─────────────────────────────
function Airplane({ isDark }: { isDark: boolean }) {
// Light mode uses darker greys so the plane reads against sand-100
const bodyTop = isDark ? '#ececec' : '#c8c8c8'
const bodyMid = isDark ? '#ffffff' : '#dedede'
const bodyBot = isDark ? '#d0d0d0' : '#b4b4b4'
const wingLight = isDark ? '#f8f8f8' : '#d8d8d8'
const wingDark = isDark ? '#bababa' : '#909090'
const tail = isDark ? '#c8c8c8' : '#a8a8a8'
const nose = isDark ? '#f4f4f4' : '#d8d8d8'
return (
<svg
className="w-[52px] sm:w-[72px] h-auto"
viewBox="0 0 72 46"
fill="none"
style={{
filter: isDark
? 'drop-shadow(0 4px 12px rgba(0,0,0,0.75)) drop-shadow(0 2px 4px rgba(0,0,0,0.5))'
: 'drop-shadow(0 2px 8px rgba(0,0,0,0.35)) drop-shadow(0 1px 3px rgba(0,0,0,0.2))',
}}
>
<defs>
<linearGradient id="rl-body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={bodyTop} />
<stop offset="40%" stopColor={bodyMid} />
<stop offset="100%" stopColor={bodyBot} />
</linearGradient>
<linearGradient id="rl-wing-t" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor={wingLight} />
<stop offset="100%" stopColor={wingDark} />
</linearGradient>
<linearGradient id="rl-wing-b" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={wingLight} />
<stop offset="100%" stopColor={wingDark} />
</linearGradient>
</defs>
{/* Top wing — root centred on body, thicker chord */}
<path d="M44,21 L28,21 L8,2 L18,0 Z" fill="url(#rl-wing-t)" />
{/* Bottom wing */}
<path d="M44,25 L28,25 L8,44 L18,46 Z" fill="url(#rl-wing-b)" />
{/* Horizontal tail stabilisers */}
<path d="M8,21 L2,14 L6,14 L12,21 Z" fill={tail} />
<path d="M8,25 L2,32 L6,32 L12,25 Z" fill={tail} />
{/* Fuselage */}
<ellipse cx="34" cy="23" rx="29" ry="7" fill="url(#rl-body)" />
{/* Nose taper */}
<path
d="M61,19.5 C66,21.5 67.5,23 67,23 C67.5,23 66,24.5 61,26.5 L58,23 Z"
fill={nose}
/>
{/* Cockpit window */}
<ellipse cx="57" cy="23" rx="4" ry="3" fill="#7ec8e8" opacity="0.9" />
</svg>
)
}
// ─── RunwayLoader ─────────────────────────────────────────────────────────────
type Phase = 'taxiing' | 'takeoff' | 'resetting'
export default function RunwayLoader() {
const containerRef = useRef<HTMLDivElement>(null)
const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const check = () => {
const card = el.closest('[data-card-theme]')
setIsDark(card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark'))
}
check()
const observer = new MutationObserver(check)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el.closest('[data-card-theme]')
if (cardWrapper) observer.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const [progress, setProgress] = useState(0)
const [phase, setPhase] = useState<Phase>('taxiing')
const [cycle, setCycle] = useState(0)
const rafRef = useRef<number>(0)
const startRef = useRef<number | undefined>(undefined)
const aliveRef = useRef(true)
const disappearedRef = useRef(false)
// Nose tip touches the right wall when the plane centre reaches this %
const WALL_PCT = 91
useEffect(() => {
aliveRef.current = true
disappearedRef.current = false
const TAXI_MS = 5500
function tick(ts: number) {
if (!aliveRef.current) return
if (!startRef.current) startRef.current = ts
const t = Math.min((ts - startRef.current) / TAXI_MS, 1)
// Realistic curve: fast to ~82% in first 75% of time, then slows
const eased = t <= 0.75
? (t / 0.75) * 82
: 82 + (1 - Math.pow(1 - (t - 0.75) / 0.25, 2)) * 18
const p = Math.min(eased, 100)
setProgress(p)
// Trigger disappear exactly when nose tip hits the right wall
if (p >= WALL_PCT && !disappearedRef.current) {
disappearedRef.current = true
setPhase('takeoff')
}
if (p < 100) {
rafRef.current = requestAnimationFrame(tick)
} else {
// Bar is full — wait for disappear animation then reset
setTimeout(() => {
if (!aliveRef.current) return
setPhase('resetting')
setTimeout(() => {
if (!aliveRef.current) return
disappearedRef.current = false
startRef.current = undefined
setProgress(0)
setPhase('taxiing')
setCycle((c) => c + 1)
rafRef.current = requestAnimationFrame(tick)
}, 400)
}, 600)
}
}
rafRef.current = requestAnimationFrame(tick)
return () => {
aliveRef.current = false
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [])
// Plane sits at the fill boundary, clamped so nose tip doesn't pass the right wall
const planePct = Math.min(progress, WALL_PCT)
// Theme-responsive track colours
const trackBg = isDark
? 'linear-gradient(to bottom, #404040, #252525)'
: 'linear-gradient(to bottom, #bab7b2, #a09d98)'
const trackShadow = isDark
? 'inset 0 4px 12px rgba(0,0,0,0.85), inset 0 -1px 3px rgba(255,255,255,0.04), 0 2px 8px rgba(0,0,0,0.5)'
: 'inset 0 3px 8px rgba(0,0,0,0.22), inset 0 -1px 3px rgba(255,255,255,0.28), 0 2px 5px rgba(0,0,0,0.12)'
const dashColor = isDark
? 'rgba(255,255,255,0.22)'
: 'rgba(255,255,255,0.42)'
return (
<div ref={containerRef} className="flex min-h-screen w-full items-center justify-center" style={{ background: isDark ? '#110F0C' : '#F5F1EA' }}>
<div className="flex w-full max-w-[440px] flex-col items-center gap-4 px-4 sm:gap-6 sm:px-6">
{/* ── Runway track ── */}
<div
className="relative h-11 w-full overflow-visible rounded-full sm:h-14"
style={{ background: trackBg, boxShadow: trackShadow }}
>
{/* Runway centreline dashes — static full-width, sits behind the fill */}
<div
className="pointer-events-none absolute top-1/2 -translate-y-px"
style={{
left: 18,
right: 18,
height: 2,
backgroundImage: `repeating-linear-gradient(to right, ${dashColor} 0, ${dashColor} 10px, transparent 10px, transparent 24px)`,
}}
/>
{/* Red fill — painted on top of dashes, revealing only the right portion */}
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${progress}%`,
background: 'linear-gradient(to right, #6b0000, #be1c1c 50%, #e03030)',
boxShadow: 'inset 0 1px 3px rgba(255,255,255,0.1)',
}}
/>
{/* Heat-shimmer exhaust behind the engines */}
{phase === 'taxiing' && progress > 30 && (
<motion.div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full"
style={{
right: `calc(${100 - planePct}% + 26px)`,
width: Math.min(((progress - 30) / 70) * 56, 56),
height: 22,
background:
'radial-gradient(ellipse at right, rgba(255,140,20,0.3), transparent)',
filter: 'blur(8px)',
}}
animate={{ opacity: [0.3, 0.75, 0.2, 0.65, 0.3] }}
transition={{ duration: 0.2, repeat: Infinity }}
/>
)}
{/* ── Airplane ── */}
<AnimatePresence>
{phase !== 'resetting' && (
<div
key={`plane-${cycle}`}
className="pointer-events-none absolute top-1/2 z-20"
style={{
left: `${planePct}%`,
transform: 'translateX(-50%) translateY(-50%)',
}}
>
{phase === 'taxiing' ? (
<Airplane isDark={isDark} />
) : (
// Liftoff: grows and slides right past the edge, then fades
<motion.div
initial={{ opacity: 1, x: 0, scale: 1 }}
animate={{ opacity: 0, x: 120, scale: 1.8 }}
transition={{ duration: 0.7, ease: 'easeIn' }}
>
<Airplane isDark={isDark} />
</motion.div>
)}
</div>
)}
</AnimatePresence>
</div>
{/* ── Progress readout ── */}
<div className="flex flex-col items-center gap-1">
<div className="flex items-baseline gap-1">
<span
className="text-2xl font-bold tabular-nums sm:text-[28px]"
style={{ color: isDark ? '#FAF7F2' : '#1C1916' }}
>
{Math.round(progress)}
</span>
<span className="text-sm font-semibold sm:text-base" style={{ color: '#9E9890' }}>%</span>
</div>
<span
className="text-[10px] font-semibold uppercase tracking-widest sm:text-[11px]"
style={{ color: isDark ? '#736D65' : '#9E9890' }}
>
{phase === 'takeoff'
? 'Taking off!'
: phase === 'resetting'
? '—'
: 'Preparing for takeoff'}
</span>
</div>
</div>
</div>
)
}
Add to your project
One command adds this component to your project.
1
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/runway-loader2
For dark mode, add the dark class to your <html> element:
<html class="dark">

