A notched aluminium laptop drawn in CSS that scales with its container. Renders children, an image or a video as the screen, and can open its lid when it scrolls into view.
Right-to-left: Hardware buttons and bezels sit on fixed physical edges of the device, so the frame keeps its shape; the screen content you place inside follows the page direction.
Overview
Last 30 days · all regions
Revenue
$48,290
+12.4%
Active users
9,214
+5.1%
p95 latency
182 ms
−8 ms
import { Activity, BarChart3, Globe, LayoutGrid, Search, Settings, Users } from "lucide-react"
import { LaptopFrame } from "@/components/ballmac/laptop-frame"
const nav = [
{ icon: LayoutGrid, label: "Overview", active: true },
{ icon: BarChart3, label: "Analytics" },
{ icon: Globe, label: "Regions" },
{ icon: Users, label: "Customers" },
{ icon: Settings, label: "Settings" },
]
const stats = [
{ label: "Revenue", value: "$48,290", delta: "+12.4%" },
{ label: "Active users", value: "9,214", delta: "+5.1%" },
{ label: "p95 latency", value: "182 ms", delta: "−8 ms" },
]
const deploys = [
{ name: "api-gateway", region: "iad1", time: "2m ago", status: "Ready" },
{ name: "web-dashboard", region: "fra1", time: "14m ago", status: "Ready" },
{ name: "billing-worker", region: "sfo1", time: "1h ago", status: "Ready" },
]
// Revenue line in a 600 × 160 box.
const line = "M0 128 C 40 120, 70 96, 110 102 S 180 76, 220 84 S 290 50, 330 62 S 400 34, 440 44 S 520 18, 600 22"
function Dashboard() {
return (
<div className="flex size-full flex-col bg-background text-[13px] text-foreground">
{/* Menu bar; the notch sits in its middle. */}
<div className="flex h-7 shrink-0 items-center gap-4 border-b bg-muted/60 px-4 text-[11px] font-medium">
<span className="font-semibold">Acme</span>
<span className="text-muted-foreground">File</span>
<span className="text-muted-foreground">View</span>
<span className="ms-auto font-mono text-muted-foreground tabular-nums">Tue 9:41</span>
</div>
<div className="flex min-h-0 flex-1">
<aside className="flex w-44 shrink-0 flex-col gap-0.5 border-e bg-muted/30 p-3">
<div className="mb-3 flex items-center gap-2 px-2">
<span className="size-5 rounded-md bg-[conic-gradient(from_200deg,var(--chart-1),var(--chart-4),var(--chart-2),var(--chart-1))]" />
<span className="text-[13px] font-semibold tracking-tight">Acme Cloud</span>
</div>
{nav.map(({ icon: Icon, label, active }) => (
<span
key={label}
className={
active
? "flex items-center gap-2 rounded-md bg-background px-2 py-1.5 font-medium shadow-xs ring-1 ring-border"
: "flex items-center gap-2 rounded-md px-2 py-1.5 text-muted-foreground"
}
>
<Icon className="size-3.5" aria-hidden="true" />
{label}
</span>
))}
</aside>
<main className="flex min-w-0 flex-1 flex-col gap-4 p-5">
<div className="flex items-center gap-3">
<div>
<p className="text-[17px] font-semibold tracking-tight">Overview</p>
<p className="text-xs text-muted-foreground">Last 30 days · all regions</p>
</div>
<span className="ms-auto flex h-7 w-44 items-center gap-2 rounded-md border bg-background px-2 text-xs text-muted-foreground">
<Search className="size-3" aria-hidden="true" /> Search
<span className="ms-auto rounded border px-1 font-mono text-[10px]">⌘K</span>
</span>
</div>
<div className="grid grid-cols-3 gap-3">
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight tabular-nums">{s.value}</p>
<p className="mt-0.5 text-[11px] font-medium text-chart-2">{s.delta}</p>
</div>
))}
</div>
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs">
<Activity className="size-3.5 text-chart-1" aria-hidden="true" />
<span className="font-medium">Revenue</span>
<span className="ms-auto text-muted-foreground">USD</span>
</div>
<svg viewBox="0 0 600 160" className="mt-2 h-32 w-full" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="laptop-demo-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0" />
</linearGradient>
</defs>
{[40, 80, 120].map((y) => (
<line key={y} x1="0" x2="600" y1={y} y2={y} stroke="var(--border)" strokeDasharray="3 5" />
))}
<path d={`${line} L 600 160 L 0 160 Z`} fill="url(#laptop-demo-fill)" />
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" />
</svg>
</div>
<div className="rounded-lg border bg-card">
{deploys.map((d, i) => (
<div key={d.name} className={`flex items-center gap-3 px-3 py-2 text-xs ${i ? "border-t" : ""}`}>
<span className="size-1.5 rounded-full bg-chart-2" aria-hidden="true" />
<span className="font-mono">{d.name}</span>
<span className="text-muted-foreground">{d.region}</span>
<span className="ms-auto text-muted-foreground">{d.time}</span>
<span className="rounded-full border px-2 py-0.5 text-[10px] font-medium">{d.status}</span>
</div>
))}
</div>
</main>
</div>
</div>
)
}
export default function LaptopFrameDemo() {
return (
<div className="w-full max-w-xl px-2 py-6">
<LaptopFrame screenWidth={900} openAnimation="in-view">
<Dashboard />
</LaptopFrame>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/laptop-frameInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/mediaCopy the source into your project.
components/ballmac/laptop-frame.tsx// Ballmac UI: Laptop Frame. https://ui.ballmac.com/components/laptop-frame "use client" import * as React from "react" import { animate, motion, useInView, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform } from "motion/react" import { cn } from "@/lib/utils" import { Media } from "@/components/ballmac/media" type LaptopFrameProps = Omit<React.ComponentProps<"div">, "children"> & { /** Screen content. Ignored when `src` or `videoSrc` is set. */ children?: React.ReactNode /** Finish of the aluminium. "auto" is silver in light mode and midnight in dark mode. */ variant?: "auto" | "silver" | "midnight" /** Image shown on the screen, cropped to fill it. */ src?: string /** Alternative text for `src`. */ alt?: string /** Video shown on the screen: muted, looping, and paused under reduced motion. */ videoSrc?: string /** Poster image for `videoSrc`. */ poster?: string /** * Width in CSS pixels that the screen content is laid out at; it is then scaled to fit the screen, * like a real display resolution. Omit to lay content out at the frame's actual size. */ screenWidth?: number /** * Opens the lid: "in-view" plays once when the laptop scrolls into view, "scroll" ties the hinge to * scroll position. Reduced motion always shows the laptop open. */ openAnimation?: "none" | "in-view" | "scroll" /** Class names for the screen element (the area inside the bezel). */ screenClassName?: string } // Aluminium palettes as CSS variables, so "auto" can switch with the theme in plain CSS. const SILVER = "[--lf-shell-hi:color-mix(in_oklch,white_97%,black)] [--lf-shell:color-mix(in_oklch,white_86%,black)] [--lf-shell-lo:color-mix(in_oklch,white_70%,black)] [--lf-edge:color-mix(in_oklch,white_60%,black)] [--lf-scoop:color-mix(in_oklch,white_74%,black)]" const MIDNIGHT = "[--lf-shell-hi:color-mix(in_oklch,white_52%,black)] [--lf-shell:color-mix(in_oklch,white_30%,black)] [--lf-shell-lo:color-mix(in_oklch,white_16%,black)] [--lf-edge:color-mix(in_oklch,white_30%,black)] [--lf-scoop:color-mix(in_oklch,white_14%,black)]" const MIDNIGHT_DARK = "dark:[--lf-shell-hi:color-mix(in_oklch,white_52%,black)] dark:[--lf-shell:color-mix(in_oklch,white_30%,black)] dark:[--lf-shell-lo:color-mix(in_oklch,white_16%,black)] dark:[--lf-edge:color-mix(in_oklch,white_30%,black)] dark:[--lf-scoop:color-mix(in_oklch,white_14%,black)]" const CLOSED_ANGLE = -90 /** Lays screen content out at `screenWidth` CSS pixels and scales it to fit, without re-rendering. */ function useScreenScale(screenWidth: number | undefined) { const ref = React.useRef<HTMLDivElement>(null) React.useEffect(() => { const screen = ref.current if (!screen || !screenWidth) return const measure = () => screen.style.setProperty("--screen-scale", String(screen.clientWidth / screenWidth)) measure() const observer = new ResizeObserver(measure) observer.observe(screen) return () => observer.disconnect() }, [screenWidth]) return ref } function ScreenContent({ children, src, alt = "", videoSrc, poster, screenWidth, reduceMotion, }: Pick<LaptopFrameProps, "children" | "src" | "alt" | "videoSrc" | "poster" | "screenWidth"> & { reduceMotion: boolean }) { if (videoSrc) { return ( <video data-slot="laptop-frame-video" className="size-full object-cover" src={videoSrc} poster={poster} autoPlay={!reduceMotion} muted loop playsInline /> ) } if (src) { // eslint-disable-next-line @next/next/no-img-element return <Media data-slot="laptop-frame-image" media={src} alt={alt} fill className="size-full" /> } if (!screenWidth) return <>{children}</> return ( <div data-slot="laptop-frame-viewport" className="absolute top-0 left-0 origin-top-left" style={{ width: screenWidth, height: "calc(100% / var(--screen-scale, 1))", transform: "scale(var(--screen-scale, 1))", }} > {children} </div> ) } function LaptopFrame({ variant = "auto", src, alt, videoSrc, poster, screenWidth, openAnimation = "none", screenClassName, className, children, ...props }: LaptopFrameProps) { const rootRef = React.useRef<HTMLDivElement>(null) const screenRef = useScreenScale(src || videoSrc ? undefined : screenWidth) const reduceMotion = !!useReducedMotion() const inView = useInView(rootRef, { once: true, amount: 0.35 }) // Hinge angle in degrees: 0 is open, -90 is closed (lid flat on the base). const angle = useMotionValue(openAnimation === "none" ? 0 : CLOSED_ANGLE) const { scrollYProgress } = useScroll({ target: rootRef, offset: ["start end", "center 55%"] }) const scrollAngle = useSpring(useTransform(scrollYProgress, [0, 1], [CLOSED_ANGLE, 0]), { stiffness: 140, damping: 26, }) React.useEffect(() => { if (openAnimation === "none" || reduceMotion) { angle.set(0) return } if (openAnimation === "scroll") { angle.set(scrollAngle.get()) return scrollAngle.on("change", (v) => angle.set(v)) } if (!inView) { angle.set(CLOSED_ANGLE) return } const controls = animate(angle, 0, { type: "spring", stiffness: 60, damping: 16, mass: 1.1, delay: 0.15 }) return () => controls.stop() }, [openAnimation, reduceMotion, inView, angle, scrollAngle]) // The screen dims as the lid closes, like a display facing away from the light. const glare = useTransform(angle, [CLOSED_ANGLE, -30, 0], [0.9, 0.35, 0]) return ( <div ref={rootRef} data-slot="laptop-frame" data-variant={variant} className={cn( "@container relative w-full select-none", variant === "silver" && SILVER, variant === "midnight" && MIDNIGHT, variant === "auto" && cn(SILVER, MIDNIGHT_DARK), className )} {...props} > {/* Lid */} <div className="relative mx-auto w-[82%] perspective-[240cqw] perspective-origin-[50%_20%]"> <motion.div data-slot="laptop-frame-lid" className="relative origin-bottom transform-3d" style={{ rotateX: angle }} > {/* Front: aluminium edge, black bezel, screen. */} <div className="relative rounded-t-[2.6cqw] rounded-b-[0.5cqw] bg-(--lf-edge) p-[0.28cqw] backface-hidden shadow-[0_0.6cqw_2cqw_-0.6cqw_rgb(0_0_0/0.35)]"> <div className="relative rounded-t-[2.35cqw] rounded-b-[0.35cqw] bg-black p-[1.05cqw] pb-[1.3cqw] shadow-[inset_0_0_0_0.12cqw_rgb(255_255_255/0.08)]"> <div ref={screenRef} data-slot="laptop-frame-screen" className={cn( "relative isolate aspect-[16/10.3] overflow-hidden rounded-t-[1.35cqw] rounded-b-[0.2cqw] bg-background text-foreground", screenClassName )} > <ScreenContent src={src} alt={alt} videoSrc={videoSrc} poster={poster} screenWidth={screenWidth} reduceMotion={reduceMotion} > {children} </ScreenContent> {/* Glass: a soft diagonal reflection plus dimming while closed. */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 bg-[linear-gradient(115deg,rgb(255_255_255/0.07)_0%,rgb(255_255_255/0.02)_38%,transparent_38.2%)]" /> <motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 bg-black" style={{ opacity: glare }} /> </div> {/* Notch with camera. */} <span aria-hidden="true" data-slot="laptop-frame-notch" className="pointer-events-none absolute top-0 left-1/2 z-20 flex h-[2.15cqw] w-[11%] -translate-x-1/2 items-center justify-center rounded-b-[0.75cqw] bg-black" > <span className="size-[0.55cqw] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(120_140_255/0.55),rgb(255_255_255/0.06)_45%,transparent_70%)] ring-[0.08cqw] ring-white/10" /> </span> </div> </div> {/* Back of the lid, seen while it is closing. */} <div aria-hidden="true" className="absolute inset-0 rotate-x-180 rounded-t-[0.5cqw] rounded-b-[2.6cqw] bg-[linear-gradient(180deg,var(--lf-shell-lo),var(--lf-shell)_55%,var(--lf-shell-hi))] backface-hidden" /> </motion.div> </div> {/* Hinge */} <div aria-hidden="true" className="relative mx-auto h-[0.75cqw] w-[70%] rounded-b-[0.6cqw] bg-[linear-gradient(180deg,rgb(0_0_0/0.85),color-mix(in_oklch,var(--lf-shell-lo)_70%,black))]" /> {/* Base */} <div aria-hidden="true" data-slot="laptop-frame-base" className="relative -mt-[0.1cqw] h-[2.1cqw] w-full"> <div className="absolute inset-0 rounded-t-[0.4cqw] rounded-b-[4cqw_100%] bg-[linear-gradient(180deg,var(--lf-shell-hi)_0%,var(--lf-shell)_28%,var(--lf-shell-lo)_100%)] shadow-[inset_0_0.1cqw_0_rgb(255_255_255/0.6),inset_0_-0.15cqw_0.2cqw_rgb(0_0_0/0.18)]" /> {/* Thumb scoop */} <div className="absolute top-0 left-1/2 h-[48%] w-[15%] -translate-x-1/2 rounded-b-[1cqw] bg-[linear-gradient(180deg,var(--lf-scoop),var(--lf-shell))] shadow-[inset_0_0.12cqw_0.25cqw_rgb(0_0_0/0.25)]" /> {/* Contact shadow */} <div className="absolute inset-x-[4%] -bottom-[1.2cqw] -z-10 h-[1.6cqw] rounded-[100%] bg-black/35 blur-[1cqw] dark:bg-black/70" /> </div> </div> ) } export { LaptopFrame, type LaptopFrameProps }Update the import paths to match your project setup.
Usage
import { LaptopFrame } from "@/components/ballmac/laptop-frame"The full example is in the Code tab above.
Examples
Midnight, opens on scroll
app
api
route.ts
lib
auth.ts
meter.ts
import { LaptopFrame } from "@/components/ballmac/laptop-frame"
const code = [
["kw", "export default async function ", "fn", "handler", "tx", "(req) {"],
["tx", " const ", "fn", "session", "tx", " = await auth(req)"],
["tx", " if (!session) ", "kw", "return ", "tx", "unauthorized()"],
["tx", "", "tx", "", "tx", ""],
["tx", " const ", "fn", "usage", "tx", " = await meter.read(session.org)"],
["kw", " return ", "tx", "Response.json({ usage, ", "fn", "plan: session.plan })"],
["tx", "}", "tx", "", "tx", ""],
]
const color = { kw: "text-chart-4", fn: "text-chart-1", tx: "text-foreground" } as const
function Editor() {
return (
<div className="flex size-full flex-col bg-background font-mono text-[13px] text-foreground">
<div className="flex h-8 shrink-0 items-center gap-4 border-b bg-card px-4 text-[11px] text-muted-foreground">
<span className="text-foreground">route.ts</span>
<span>auth.ts</span>
<span>meter.ts</span>
</div>
<div className="flex min-h-0 flex-1">
<div className="w-40 shrink-0 space-y-1.5 border-e bg-card/60 p-3 text-[11px] text-muted-foreground">
<p className="text-foreground">app</p>
<p className="ps-3">api</p>
<p className="ps-6 text-foreground">route.ts</p>
<p className="ps-3">lib</p>
<p className="ps-6">auth.ts</p>
<p className="ps-6">meter.ts</p>
</div>
<div className="flex-1 p-4 leading-7">
{code.map((row, i) => (
<div key={i} className="flex gap-5 whitespace-pre">
<span className="w-4 text-end text-muted-foreground/60 tabular-nums">{i + 1}</span>
<span>
{[0, 2, 4].map((j) => (
<span key={j} className={color[row[j] as keyof typeof color]}>
{row[j + 1]}
</span>
))}
</span>
</div>
))}
<div className="mt-6 rounded-md border bg-card p-3 text-[12px] text-muted-foreground">
<span className="text-chart-2">✓</span> Compiled in 212 ms · 0 type errors
</div>
</div>
</div>
</div>
)
}
export default function LaptopFrameScroll() {
return (
<div className="w-full max-w-xl px-2 py-6">
<LaptopFrame variant="midnight" screenWidth={860} openAnimation="scroll" screenClassName="dark">
<Editor />
</LaptopFrame>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
childrenScreen content. Ignored when `src` or `videoSrc` is set. | React.ReactNode | — |
variantFinish of the aluminium. "auto" is silver in light mode and midnight in dark mode. | "auto" | "silver" | "midnight" | "auto" |
srcImage shown on the screen, cropped to fill it. | string | — |
altAlternative text for `src`. | string | — |
videoSrcVideo shown on the screen: muted, looping, and paused under reduced motion. | string | — |
posterPoster image for `videoSrc`. | string | — |
screenWidthWidth in CSS pixels that the screen content is laid out at; it is then scaled to fit the screen, like a real display resolution. Omit to lay content out at the frame's actual size. | number | — |
openAnimationOpens the lid: "in-view" plays once when the laptop scrolls into view, "scroll" ties the hinge to scroll position. Reduced motion always shows the laptop open. | "none" | "in-view" | "scroll" | "none" |
screenClassNameClass names for the screen element (the area inside the bezel). | string | — |
Also accepts the standard attributes of its root element.
Use with AI
Wrap a product screen in <LaptopFrame>: children become the display. Pass screenWidth (e.g. 1280) to lay the content out at a desktop resolution and scale it down to fit; openAnimation="in-view" or "scroll" swings the lid open. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Laptop Frame (@ballmac/laptop-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Landing-page heroes that show a desktop or web app in context
- Feature sections with a live React screen instead of a static screenshot
- Scroll-driven product reveals (openAnimation="scroll")
Not for
- Showing a website with its URL and browser chrome (use browser-frame)
- Mobile app screens (use phone-frame)
Registry JSON: https://ui.ballmac.com/r/laptop-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
Browser Frame
A Safari-style window with traffic lights, a padlocked address field and optional tabs. Children render as the page, either at natural size or laid out at desktop width and scaled to fit.
Phone Frame
A modern phone drawn in CSS: titanium band, side buttons, Dynamic Island, status bar and home indicator. Children render as the screen, laid out at a phone resolution and scaled to fit.
Android Frame
A modern Android phone drawn in CSS: satin frame in porcelain, obsidian or sage, centered punch-hole camera, Material-style status bar and gesture or three-button navigation. Children render at phone resolution, scaled to fit.
Tablet Frame
A modern tablet drawn in CSS: brushed aluminum frame, thin glass bezel, front camera, status bar and home indicator. Landscape or portrait with the same proportions; children render as the screen at tablet resolution, scaled to fit.