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.
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.
Good morning, Alex
Here is how the store is doing this week.
Revenue
$48,920
+12.4%
Active users
8,214
+5.1%
Conversion
3.8%
+0.6%
Sales, last 12 weeks
Weeklyimport { BarChart3, Bell, CreditCard, Home, Inbox, Search, Settings, Users } from "lucide-react"
import { TabletFrame } from "@/components/ballmac/tablet-frame"
const nav = [
{ icon: Home, label: "Overview", active: true },
{ icon: BarChart3, label: "Reports" },
{ icon: Users, label: "Customers" },
{ icon: Inbox, label: "Inbox" },
{ icon: CreditCard, label: "Billing" },
{ icon: Settings, label: "Settings" },
]
const stats = [
{ label: "Revenue", value: "$48,920", delta: "+12.4%" },
{ label: "Active users", value: "8,214", delta: "+5.1%" },
{ label: "Conversion", value: "3.8%", delta: "+0.6%" },
]
const bars = [38, 52, 44, 66, 58, 74, 62, 84, 70, 92, 80, 96]
function Dashboard() {
return (
<div className="flex h-full text-[15px]">
<aside className="flex w-[210px] shrink-0 flex-col gap-1 border-e bg-muted/40 p-4">
<p className="px-3 pt-1 pb-3 text-[19px] font-bold tracking-tight">Northwind</p>
{nav.map((n) => (
<span key={n.label} className={`flex items-center gap-3 rounded-xl px-3 py-2.5 font-medium ${n.active ? "bg-foreground text-background" : "text-muted-foreground"}`}>
<n.icon className="size-[18px]" aria-hidden="true" />
{n.label}
</span>
))}
</aside>
<main className="flex min-w-0 flex-1 flex-col gap-5 p-6">
<div className="flex items-center gap-3">
<div>
<p className="text-[26px] font-bold tracking-tight">Good morning, Alex</p>
<p className="text-muted-foreground">Here is how the store is doing this week.</p>
</div>
<span className="ms-auto flex h-10 w-56 items-center gap-2 rounded-full border bg-background px-4 text-muted-foreground">
<Search className="size-4" aria-hidden="true" /> Search
</span>
<span className="flex size-10 items-center justify-center rounded-full border bg-background">
<Bell className="size-[18px]" aria-hidden="true" />
</span>
</div>
<div className="grid grid-cols-3 gap-4">
{stats.map((s) => (
<div key={s.label} className="rounded-2xl border bg-card p-4">
<p className="text-muted-foreground">{s.label}</p>
<p className="mt-1 text-[28px] font-bold tracking-tight tabular-nums">{s.value}</p>
<p className="text-[13px] font-semibold text-chart-2">{s.delta}</p>
</div>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border bg-card p-5">
<div className="flex items-center justify-between">
<p className="font-semibold">Sales, last 12 weeks</p>
<span className="rounded-full bg-muted px-3 py-1 text-[13px] font-medium">Weekly</span>
</div>
<div className="mt-4 flex min-h-0 flex-1 items-end gap-3">
{bars.map((h, i) => (
<span key={i} className="flex-1 rounded-t-lg bg-[linear-gradient(to_top,var(--chart-1),var(--chart-4))]" style={{ height: `${h}%`, opacity: 0.55 + i * 0.04 }} />
))}
</div>
</div>
</main>
</div>
)
}
export default function TabletFrameDemo() {
return (
<div className="flex w-full justify-center px-2 py-6">
<TabletFrame screenWidth={1194} className="max-w-[680px]">
<Dashboard />
</TabletFrame>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/tablet-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/tablet-frame.tsx// Ballmac UI: Tablet Frame. https://ui.ballmac.com/components/tablet-frame "use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { Media } from "@/components/ballmac/media" type TabletFrameProps = Omit<React.ComponentProps<"div">, "children"> & { /** Screen content, laid out below the status bar. Ignored when `src` or `videoSrc` is set. */ children?: React.ReactNode /** Which way the tablet is held. The frame keeps the real aspect ratio either way. */ orientation?: "landscape" | "portrait" /** Finish of the aluminum frame. "auto" is silver in light mode and space black in dark mode. */ variant?: "auto" | "silver" | "black" /** 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 (1194 landscape or 834 portrait match an 11" tablet); * it is then scaled to fit. Omit to lay content out at the frame's actual size. */ screenWidth?: number /** Show the status bar (time, date, Wi-Fi, battery). */ statusBar?: boolean /** Time shown in the status bar. */ time?: string /** Date shown next to the time. */ date?: string /** Show the home indicator bar at the bottom of the screen. */ homeIndicator?: boolean /** Class names for the screen element (inside the bezel). Use "dark" to give the screen its own theme. */ screenClassName?: string } const SILVER = "[--tf-hi:color-mix(in_oklch,white_96%,black)] [--tf-mid:color-mix(in_oklch,white_82%,black)] [--tf-lo:color-mix(in_oklch,white_62%,black)]" const BLACK = "[--tf-hi:color-mix(in_oklch,white_36%,black)] [--tf-mid:color-mix(in_oklch,white_18%,black)] [--tf-lo:color-mix(in_oklch,white_9%,black)]" const BLACK_DARK = "dark:[--tf-hi:color-mix(in_oklch,white_36%,black)] dark:[--tf-mid:color-mix(in_oklch,white_18%,black)] dark:[--tf-lo:color-mix(in_oklch,white_9%,black)]" /** 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 } /** * A modern tablet drawn in CSS: aluminum frame with a brushed highlight, thin glass bezel, front camera, * status bar and home indicator. Sizes use one unit (`--u`, one percent of the short side), so portrait and * landscape share the same proportions. Children render as the screen, laid out at a tablet resolution and scaled to fit. */ function TabletFrame({ orientation = "landscape", variant = "auto", src, alt = "", videoSrc, poster, screenWidth, statusBar = true, time = "9:41 AM", date = "Thu Oct 1", homeIndicator = true, screenClassName, className, children, ...props }: TabletFrameProps) { const landscape = orientation === "landscape" const media = !!(src || videoSrc) const viewportRef = useScreenScale(media ? undefined : (screenWidth ?? undefined)) const reduceMotion = !!useReducedMotion() return ( <div data-slot="tablet-frame" data-variant={variant} data-orientation={orientation} className={cn( "@container relative w-full select-none", landscape ? "aspect-[247.6/178.5] max-w-[640px] [--u:0.7209cqw]" : "aspect-[178.5/247.6] max-w-[460px] [--u:1cqw]", variant === "silver" && SILVER, variant === "black" && BLACK, variant === "auto" && cn(SILVER, BLACK_DARK), className )} {...props} > {/* Side buttons: power on the top edge, volume near the top-right of a landscape tablet. */} {(landscape ? ["top-[calc(var(--u)*-0.7)] left-[7%] h-[calc(var(--u)*1.1)] w-[4.5%] rounded-t-[0.4cqw]", "top-[calc(var(--u)*-0.7)] right-[7%] h-[calc(var(--u)*1.1)] w-[7%] rounded-t-[0.4cqw]"] : ["top-[calc(var(--u)*-0.7)] right-[9%] h-[calc(var(--u)*1.1)] w-[8%] rounded-t-[0.4cqw]", "right-[calc(var(--u)*-0.7)] top-[9%] h-[8%] w-[calc(var(--u)*1.1)] rounded-r-[0.4cqw]"] ).map((position) => ( <span key={position} aria-hidden="true" className={cn("absolute bg-[linear-gradient(90deg,var(--tf-lo),var(--tf-hi)_50%,var(--tf-lo))]", position)} /> ))} {/* Aluminum frame */} <div data-slot="tablet-frame-body" className="absolute inset-0 rounded-[calc(var(--u)*7.4)] bg-[linear-gradient(135deg,var(--tf-hi),var(--tf-mid)_32%,var(--tf-lo)_58%,var(--tf-mid)_82%,var(--tf-hi))] p-[calc(var(--u)*0.55)] shadow-[inset_0_0_0_0.2cqw_rgb(255_255_255/0.14),0_2.4cqw_6cqw_-1.6cqw_rgb(0_0_0/0.4),0_0.4cqw_1.2cqw_rgb(0_0_0/0.2)]" > {/* Glass bezel */} <div className="relative size-full rounded-[calc(var(--u)*6.9)] bg-black p-[calc(var(--u)*3.1)] shadow-[inset_0_0_0_0.25cqw_rgb(255_255_255/0.07)]"> {/* Front camera, on the long edge */} <span aria-hidden="true" data-slot="tablet-frame-camera" className={cn( "absolute z-30 size-[calc(var(--u)*1.1)] rounded-full bg-[radial-gradient(circle_at_35%_35%,rgb(90_110_200/0.55),rgb(255_255_255/0.05)_50%,transparent_72%)] ring-[0.18cqw] ring-white/10", landscape ? "top-[calc(var(--u)*1)] left-1/2 -translate-x-1/2" : "top-1/2 left-[calc(var(--u)*1)] -translate-y-1/2" )} /> <div data-slot="tablet-frame-screen" className={cn( "relative isolate flex size-full flex-col overflow-hidden rounded-[calc(var(--u)*4.4)] bg-background text-foreground", screenClassName )} > {media ? ( videoSrc ? ( <video data-slot="tablet-frame-video" className="absolute inset-0 size-full object-cover" src={videoSrc} poster={poster} autoPlay={!reduceMotion} muted loop playsInline /> ) : ( // eslint-disable-next-line @next/next/no-img-element <Media data-slot="tablet-frame-image" media={src} alt={alt} fill className="absolute inset-0" /> ) ) : null} {statusBar && ( <div data-slot="tablet-frame-status-bar" className="relative z-20 flex h-[calc(var(--u)*4.6)] shrink-0 items-center justify-between px-[calc(var(--u)*4.6)] text-[calc(var(--u)*1.9)] font-semibold tracking-tight text-foreground" > <span className="flex gap-[calc(var(--u)*1.4)] tabular-nums"> <span>{time}</span> <span className="font-medium opacity-80">{date}</span> </span> <span aria-hidden="true" className="flex items-center gap-[calc(var(--u)*1.1)]"> <svg viewBox="0 0 16 12" className="h-[calc(var(--u)*1.8)]" fill="currentColor"> <path d="M8 2.2c2.4 0 4.6.9 6.2 2.5l1.3-1.3A10.6 10.6 0 0 0 8 .4 10.6 10.6 0 0 0 .5 3.4l1.3 1.3A8.8 8.8 0 0 1 8 2.2Z" /> <path d="M8 5.8c1.4 0 2.7.5 3.7 1.4L13 5.9A7 7 0 0 0 8 4a7 7 0 0 0-5 1.9l1.3 1.3c1-.9 2.3-1.4 3.7-1.4Z" /> <path d="M8 9.3c.5 0 1 .2 1.3.5L8 11.6 6.7 9.8c.3-.3.8-.5 1.3-.5Z" /> </svg> <span className="text-[calc(var(--u)*1.7)] font-medium opacity-80">100%</span> <svg viewBox="0 0 27 13" className="h-[calc(var(--u)*1.9)]"> <rect x="0.5" y="0.5" width="23" height="12" rx="3.6" fill="none" stroke="currentColor" strokeOpacity="0.4" /> <rect x="2" y="2" width="20" height="9" rx="2.2" fill="currentColor" /> <path d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2Z" fill="currentColor" fillOpacity="0.45" /> </svg> </span> </div> )} {!media && ( <div ref={viewportRef} data-slot="tablet-frame-content" className="relative min-h-0 flex-1"> {screenWidth ? ( <div 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> ) : ( children )} </div> )} {homeIndicator && ( <span aria-hidden="true" data-slot="tablet-frame-home-indicator" className="pointer-events-none absolute bottom-[calc(var(--u)*1.2)] left-1/2 z-30 h-[calc(var(--u)*0.7)] w-[calc(var(--u)*18)] -translate-x-1/2 rounded-full bg-foreground/85" /> )} {/* Glass reflection */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-40 bg-[linear-gradient(115deg,rgb(255_255_255/0.07)_0%,rgb(255_255_255/0.015)_34%,transparent_34.2%)]" /> </div> </div> </div> </div> ) } export { TabletFrame, type TabletFrameProps }Update the import paths to match your project setup.
Usage
import { TabletFrame } from "@/components/ballmac/tablet-frame"The full example is in the Code tab above.
Examples
Portrait reader
Chapter 3
The long way around the lake
By the time the fog lifted, the ferry had already crossed twice. Maren stood at the rail with her coat buttoned to the chin, watching the far shore resolve from a grey smudge into a line of small, lit windows.
She had promised herself she would not look at the letter again. It lay folded in her pocket, soft at the creases, as if it had already been read a hundred times by someone else.
Some distances are only measured in what you chose not to say.
The ferry horn sounded once, low and patient, and the whole lake seemed to lean toward the sound.
import { Bookmark, ChevronLeft, Share, Type } from "lucide-react"
import { TabletFrame } from "@/components/ballmac/tablet-frame"
function Reader() {
return (
<div className="flex h-full flex-col bg-[color-mix(in_oklch,var(--chart-3)_8%,var(--background))] px-16 pt-6 text-[22px]">
<div className="flex items-center justify-between text-muted-foreground">
<span className="flex items-center gap-1 font-medium text-foreground"><ChevronLeft className="size-7 rtl:rotate-180" aria-hidden="true" /> Library</span>
<span className="flex items-center gap-5">
<Type className="size-7" aria-hidden="true" />
<Bookmark className="size-7" aria-hidden="true" />
<Share className="size-7" aria-hidden="true" />
</span>
</div>
<p className="mt-14 text-[20px] font-semibold tracking-[0.18em] text-chart-5 uppercase">Chapter 3</p>
<h3 className="mt-3 font-serif text-[72px] leading-[1.02] font-bold tracking-tight">The long way around the lake</h3>
<p className="mt-8 font-serif text-[31px] leading-[1.6] text-foreground/85">
By the time the fog lifted, the ferry had already crossed twice. Maren stood at the rail with her coat buttoned to the chin, watching the far shore
resolve from a grey smudge into a line of small, lit windows.
</p>
<p className="mt-6 font-serif text-[31px] leading-[1.6] text-foreground/85">
She had promised herself she would not look at the letter again. It lay folded in her pocket, soft at the creases, as if it had already been
read a hundred times by someone else.
</p>
<blockquote className="mt-10 border-s-4 border-chart-5 ps-6 font-serif text-[34px] leading-snug text-foreground italic">
Some distances are only measured in what you chose not to say.
</blockquote>
<p className="mt-8 font-serif text-[31px] leading-[1.6] text-foreground/85">
The ferry horn sounded once, low and patient, and the whole lake seemed to lean toward the sound.
</p>
<div className="mt-auto flex items-center gap-4 pb-10 text-[20px] text-muted-foreground">
<span>42 of 318</span>
<span className="h-1 flex-1 rounded-full bg-muted"><span className="block h-full w-[13%] rounded-full bg-chart-5" /></span>
<span>13%</span>
</div>
</div>
)
}
export default function TabletFramePortrait() {
return (
<div className="flex w-full justify-center px-2 py-6">
<TabletFrame orientation="portrait" variant="black" screenWidth={834} className="max-w-[380px]">
<Reader />
</TabletFrame>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
childrenScreen content, laid out below the status bar. Ignored when `src` or `videoSrc` is set. | React.ReactNode | — |
orientationWhich way the tablet is held. The frame keeps the real aspect ratio either way. | "landscape" | "portrait" | "landscape" |
variantFinish of the aluminum frame. "auto" is silver in light mode and space black in dark mode. | "auto" | "silver" | "black" | "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 (1194 landscape or 834 portrait match an 11" tablet); it is then scaled to fit. Omit to lay content out at the frame's actual size. | number | — |
statusBarShow the status bar (time, date, Wi-Fi, battery). | boolean | true |
timeTime shown in the status bar. | string | "9:41 AM" |
dateDate shown next to the time. | string | "Thu Oct 1" |
homeIndicatorShow the home indicator bar at the bottom of the screen. | boolean | true |
screenClassNameClass names for the screen element (inside the bezel). Use "dark" to give the screen its own theme. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Static | Purely visual: the frame adds no focusable elements and hides its decoration from assistive tech |
| Reduced motion | Video on the screen is not autoplayed |
Use with AI
Wrap a tablet screen in <TabletFrame screenWidth={1194}>: children render below a status bar at 1194 CSS px wide (834 in portrait) and are scaled to the frame's width. Set the width with className; the height follows the aspect ratio. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tablet Frame (@ballmac/tablet-frame) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing a dashboard or app on a landing page
- Pairing with phone-frame and laptop-frame in a device lineup
Not for
- Phone-sized screens (phone-frame, android-frame)
- Real screenshots that already include a bezel
Registry JSON: https://ui.ballmac.com/r/tablet-frame.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
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.
Laptop Frame
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.
Watch Frame
A smartwatch drawn in CSS: rounded aluminum case, digital crown and side button, curved glass and a fading strap in sport, loop or leather. The screen is always dark and children render 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.