Tablet Frame
A device that floats over a game or a full-bleed canvas. A 16:10 screen centered over everything, that closes without losing its state and lowers out of the way to show what is behind it.
Installation#
Usage#
import { TabletFrame, TabletFrameScreen } from "@/components/ui/tablet-frame"
export function Example({ open }: { open: boolean }) {
return (
<TabletFrame open={open}>
<TabletFrameScreen>
<Layout className="h-full">…</Layout>
</TabletFrameScreen>
</TabletFrame>
)
}Anatomy#
import { TabletFrame, TabletFrameScreen } from "@/components/ui/tablet-frame"
<TabletFrame>
<TabletFrameScreen />
</TabletFrame>Open and closed#
open (true by default) shows the device. Closing it fades it out with a small drop, but never unmounts it: the tree stays mounted and laid out, so a form keeps what was typed, a map keeps its size and a tab keeps its scroll. While closed it is inert, so focus and the accessibility tree leave with the picture. Type a note, close the tablet and open it again.
Lowered#
lowered drops the device by 80% of its own height, so its top edge stays in view as a handle, the way a player lowers a tablet to look at the street. Wire it to a held key or button: it eases down while held and back up on release. The drop is a share of the screen, not of the window, so it reads the same in any container.
Scaling#
The screen is 112.5rem × 70.3125rem, 1800 by 1125 at 16px, and below that it narrows with the window to keep a 1rem margin. To fill a share of any display, scale the root font size: the device, its text and its spacing grow and shrink together, and every popover, menu and tooltip stays on its trigger, which a CSS zoom on the device would not do.
import * as React from "react"
/** Fit the device to 88% of the window by scaling the rem. */
export function useTabletScale(fill = 0.88) {
React.useLayoutEffect(() => {
const root = document.documentElement
const fit = () => {
const scale = Math.min((innerWidth * fill) / 1800, (innerHeight * fill) / 1125)
root.style.fontSize = `${16 * scale}px`
}
fit()
addEventListener("resize", fit)
return () => {
removeEventListener("resize", fit)
root.style.fontSize = ""
}
}, [fill])
}Inside a container#
The frame is fixed over the window. To preview it inside a box (a docs page, a storybook, a marketing shot), give the box a transform: it becomes the containing block, and the frame centers in it. Size the screen to the box with className; it keeps its 16:10 ratio. Every example on this page is built this way.
<div className="relative aspect-[16/10] w-full overflow-hidden transform-gpu">
<TabletFrame>
<TabletFrameScreen className="w-[88%]">…</TabletFrameScreen>
</TabletFrame>
</div>API reference#
TabletFrame
The layer: fixed inset-0, transparent to the pointer, centering the screen. Closed, it is inert. Every div prop is forwarded; className is merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | true | Shows the device. Closed, it fades with a small drop and stays mounted, so its tree keeps its state. |
| lowered | boolean | false | Drops the device by 80% of its own height, so its top edge stays in view as a handle. |
| className | string | - | Merged last onto the layer. |
| Data attribute | Description |
|---|---|
| data-slot | "tablet-frame". |
| data-state | "open" or "closed". |
| data-open | true or false, mirroring open. |
| data-lowered | Present while lowered. |
TabletFrameScreen
The device: a 16:10 column that clips its content to rounded-2xl, on the app canvas (bg-canvas, declared as --surface). A docked Layout fits it concentrically. Every div prop is forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Merged last, so a w-* resizes the device at the same ratio. |
| Data attribute | Description |
|---|---|
| data-slot | "tablet-frame-screen". |
| CSS variable | Description |
|---|---|
| --surface | Set to var(--canvas), the floor a docked Layout sits on. |