Browser Mockup
A macOS-style browser window for landing page heroes, the URL types itself, a loading bar sweeps, and your content unblurs into focus.
The frame modern SaaS heroes actually ship: not a device render, a browser
window. The chrome is drawn entirely with this system's surfaces (raised
panel, top-lit edges, a recessed address well), and the whole thing boots like
a real page visit: the window rises in, the chrome cascades on, the address
types itself character by character, the accent loading bar sweeps the
toolbar, and the blank white sheet settles into the page. The page is a
children slot, a screenshot, a video, or live UI; with nothing passed it
rests as a plain grey stage. Built with Motion.
The reload control is real: press it and the load sequence replays. Hover the traffic lights for the macOS glyphs.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/browser-mockup.tsx.
'use client';import * as React from 'react';import { motion, useInView, useReducedMotion } from 'motion/react';import { ChevronLeft, ChevronRight, Lock, Plus, RotateCw } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const TYPE_MS = 30;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--mock-accent)]';export type BrowserMockupProps = { children?: React.ReactNode; url?: string; accent?: string; openDelay?: number; label?: string; className?: string;};export function BrowserMockup({ children, url = 'northwind.app/dashboard', accent = '#f0883e', openDelay = 200, label, className,}: BrowserMockupProps) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLDivElement>(null); const inView = useInView(ref, { once: true, amount: 0.4 }); const [typed, setTyped] = React.useState(0); const [loads, setLoads] = React.useState(0); const [loading, setLoading] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const shown = reduce || inView; const base = openDelay / 1000; React.useEffect(() => { if (!inView && !reduce) return; if (reduce) { setTyped(url.length); setLoaded(true); return; } let iv: ReturnType<typeof setInterval> | undefined; const t = setTimeout(() => { let i = 0; iv = setInterval(() => { i += 1; setTyped(i); if (i >= url.length) { clearInterval(iv); setLoads((n) => n + 1); setLoading(true); } }, TYPE_MS); }, openDelay + 650); return () => { clearTimeout(t); if (iv) clearInterval(iv); }; }, [inView, reduce, url, openDelay]); const reload = () => { if (loading) return; setLoaded(false); setLoads((n) => n + 1); setLoading(true); }; const typing = typed > 0 && typed < url.length; const vars = { '--mock-accent': accent } as React.CSSProperties; const chrome = (i: number) => reduce ? {} : { initial: { opacity: 0, y: 4 }, animate: shown ? { opacity: 1, y: 0 } : undefined, transition: { duration: 0.4, ease: EASE_OUT, delay: base + 0.3 + i * 0.045 }, }; return ( <div ref={ref} role={label ? 'img' : undefined} aria-label={label} style={vars} className={cn('relative w-full max-w-[640px]', className)} > <motion.div initial={reduce ? false : { opacity: 0, y: 22, scale: 0.97 }} animate={shown ? { opacity: 1, y: 0, scale: 1 } : undefined} transition={{ duration: 0.7, ease: EASE_OUT, delay: base }} className={cn( 'bloom-edge relative overflow-hidden rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_14px_36px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_18px_44px_-12px_rgba(0,0,0,0.6)]', )} > <div className="relative flex h-9 items-center gap-2 px-2.5"> <motion.span aria-hidden className="group/lights flex items-center gap-[6px] pl-0.5" {...chrome(0)}> <TrafficLight color="#ff5f57" glyph="close" /> <TrafficLight color="#febc2e" glyph="min" /> <TrafficLight color="#28c840" glyph="max" /> </motion.span> <motion.span aria-hidden className="ml-1 hidden items-center gap-0.5 text-neutral-400 dark:text-[#6d6d6d] sm:flex" {...chrome(1)} > <ChevronLeft size={14} strokeWidth={2} className="text-neutral-500 dark:text-[#929292]" /> <ChevronRight size={14} strokeWidth={2} /> </motion.span> <motion.span className={cn( 'bloom-edge relative flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-[8px] px-2', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} {...chrome(2)} > <motion.span aria-hidden initial={reduce ? false : { opacity: 0, scale: 0.8 }} animate={typed >= url.length ? { opacity: 1, scale: 1 } : undefined} transition={{ duration: 0.3, ease: EASE_OUT }} className="text-neutral-400 dark:text-[#6d6d6d]" > <Lock size={9} strokeWidth={2.25} /> </motion.span> <span className="truncate text-[11px] font-medium tracking-[-0.01em] text-neutral-600 dark:text-[#b4b4b4]"> {url.slice(0, typed)} </span> {typing && ( <span aria-hidden className="-ml-1 h-[11px] w-px animate-pulse bg-neutral-500 dark:bg-[#b4b4b4]" /> )} <motion.button type="button" onClick={reload} aria-label="Reload the preview" animate={reduce ? undefined : { rotate: loads * 360 }} transition={{ duration: 0.7, ease: EASE_OUT }} whileTap={reduce ? undefined : { scale: 0.9 }} className={cn( 'ml-auto grid size-4.5 shrink-0 place-items-center rounded-[5px] text-neutral-500 dark:text-[#929292]', 'hover:text-neutral-900 dark:hover:text-white', FOCUS, )} > <RotateCw size={10.5} strokeWidth={2.25} /> </motion.button> </motion.span> <motion.span aria-hidden className="hidden text-neutral-400 dark:text-[#6d6d6d] sm:block" {...chrome(3)}> <Plus size={14} strokeWidth={2} /> </motion.span> <motion.span aria-hidden key={loads} initial={{ scaleX: 0, opacity: loads > 0 ? 1 : 0 }} animate={loading ? { scaleX: 1, opacity: 1 } : { opacity: 0 }} transition={ loading ? { scaleX: { duration: 0.75, ease: EASE_OUT }, opacity: { duration: 0.1 } } : { opacity: { duration: 0.25, ease: EASE_OUT } } } onAnimationComplete={() => { if (loading) { setLoading(false); setLoaded(true); } }} style={{ transformOrigin: '0 50%', backgroundColor: 'var(--mock-accent)' }} className="absolute bottom-0 left-0 h-[2px] w-full" /> </div> <div aria-hidden className="h-px w-full bg-gradient-to-r from-black/[0.04] via-black/[0.09] to-black/[0.04] dark:from-white/[0.03] dark:via-white/[0.1] dark:to-white/[0.03]" /> <div className="relative aspect-[16/10] w-full overflow-hidden bg-[#e4e4e7] dark:bg-[#151517]"> <motion.div aria-hidden initial={false} animate={{ opacity: loaded ? 0 : 1 }} transition={{ duration: loaded ? 0.5 : 0.15, ease: EASE_OUT }} className="pointer-events-none absolute inset-0 z-10 bg-white dark:bg-[#232326]" /> <motion.div initial={reduce ? false : { opacity: 0, filter: 'blur(10px)', scale: 1.02 }} animate={ loaded ? { opacity: 1, filter: 'blur(0px)', scale: 1 } : { opacity: 0, filter: reduce ? 'blur(0px)' : 'blur(10px)', scale: reduce ? 1 : 1.02 } } transition={ loaded ? { duration: 0.6, ease: EASE_OUT } : { duration: 0.2, ease: EASE_OUT } } className="h-full w-full" > {children} </motion.div> </div> </motion.div> </div> );}function TrafficLight({ color, glyph }: { color: string; glyph: 'close' | 'min' | 'max' }) { return ( <span style={{ backgroundColor: color }} className="grid size-[11px] place-items-center rounded-full shadow-[inset_0_0_0_0.5px_rgba(0,0,0,0.12),inset_0_1px_1px_0_rgba(255,255,255,0.3)]" > <svg viewBox="0 0 6 6" className="size-[6px] opacity-0 transition-opacity duration-150 group-hover/lights:opacity-100" stroke="rgba(0,0,0,0.45)" strokeWidth="1.1" strokeLinecap="round" fill="none" aria-hidden > {glyph === 'close' && <path d="M1.2 1.2l3.6 3.6M4.8 1.2L1.2 4.8" />} {glyph === 'min' && <path d="M1 3h4" />} {glyph === 'max' && <path d="M3 1v4M1 3h4" />} </svg> </span> );}Usage
import { BrowserMockup } from '@/components/ui/browser-mockup';
export default function Example() {
return (
<BrowserMockup url="yourapp.com/dashboard" label="App dashboard preview">
<img src="/screenshots/dashboard.png" alt="" className="h-full w-full object-cover" />
</BrowserMockup>
);
}The page keeps a 16:10 stage; size the whole window by constraining the
wrapper (className="max-w-[800px]").
Examples
Anything you pass fills the stage edge to edge and inherits the load sequence.

Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Page content. Fills the 16:10 stage; a plain grey stage when omitted. |
url | string | "northwind.app/dashboard" | Address typed into the URL bar. |
accent | string | "#f0883e" | Loading bar and reload focus ring. |
openDelay | number | 200 | Delay in ms between entering the viewport and the reveal. |
label | string | - | Accessible name (adds role="img"). Omit when the page content is interactive. |
className | string | - | Forwarded to the wrapper; use it to size the window. |