liten

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.

Terminal
bun add motion

Copy the component into components/ui/browser-mockup.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
childrenReact.ReactNode-Page content. Fills the 16:10 stage; a plain grey stage when omitted.
urlstring"northwind.app/dashboard"Address typed into the URL bar.
accentstring"#f0883e"Loading bar and reload focus ring.
openDelaynumber200Delay in ms between entering the viewport and the reveal.
labelstring-Accessible name (adds role="img"). Omit when the page content is interactive.
classNamestring-Forwarded to the wrapper; use it to size the window.
On this page0%