Tablet frame
NewThe device frame around a game’s tablet screens: a navy screen with a glowing edge, a carved bronze bezel, L/R screen tabs and button hints, and a power-on sweep.
tablet-frame/tablet-frame.tsxSpark Charge
Set a charge
Place a charge that bursts on command.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- The device around a game’s in-world tablet or handheld: the abilities, map and album pages the player opens from the pause button.
- A few full-screen pages read in a row and paged with the shoulder buttons, L and R (or Q and E), where the player should see which page is either side.
- Pages played with a controller: the footer says what each button does on this page, and every hint is also a real button for mouse and touch.
- A menu that should feel like switching a device on: the cyan sweep and the content rising in mark the moment the game pauses.
- Any game page that has to read on a light or a dark site: the bezel and screen bring their own dark, only the shadow follows the page.
When not to use
- Moving between the sections of a pause menu with many tabs. Use menu tabs, which show every section at once instead of only the neighbours.
- A dialog or a confirmation over the game. Use a game modal; this frame is a whole screen, not a box over one.
- An overlay shown while playing (health, ammo, a map in the corner). Use the HUD pieces; the frame hides the game behind it.
- A device mock-up for a product page or an app screenshot. Use a plain phone or tablet frame; this one is a fantasy prop with carved bronze.
- Pages read with a mouse and a scroll wheel only. Use ordinary tabs and a page; the shoulder neighbours and button hints assume a controller.
You can add this component with the shadcn CLI or by hand:
Into components/tablet-frame of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/tablet-frame.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { useState } from 'react';import { TabletFrame, type TabletTab } from './tablet-frame';const TABS: TabletTab[] = [ { id: 'skills', label: 'Skills' }, { id: 'map', label: 'Map' }, { id: 'journal', label: 'Journal' },];export function Tablet({ onClose }: { onClose: () => void }) { const [tab, setTab] = useState('map'); return ( <TabletFrame title="Tablet" tabs={TABS} tab={tab} onTabChange={setTab} autoplay={false} footerHints={[ { label: 'Back', button: 'B', onPress: onClose }, { label: 'Select', button: 'A' }, ]} > {(current) => <p>{current?.label} goes here.</p>} </TabletFrame> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The frame every tablet screen in a game lives in: abilities, map, album. A deep navy screen with a faint engraved texture and a blue-cyan glow along its inner edge, held in dark flecked stone carved with bronze circuit traces, heaviest at the corners. The top bar names the screen with its neighbours on each side and their shoulder buttons; the bottom bar lists what the buttons do here. Opening the tablet runs a cyan glow round the screen’s edge before the content rises in. Press L and R (or Q and E) to change screen, or the letter of a hint. It brings its own dark device, so it reads on a light or a dark page, and it is drawn in SVG and CSS, so it stays sharp at any size.
- The device is a three-row grid: the top band, the screen and the bottom band, at 16 / 9 (4 / 3 under 600 px) and taller if its content needs it. Every size is a multiple of --u, 1 px when the frame is 960 px wide (clamped between 0.36 and 1.5 px); the bands, text and glyphs have floors so a phone stays readable.
- The carving is vector only. Each corner is an inline SVG whose traces, rings and studs are one geometry drawn seven times through <use>, as a relief: two faint wide blue strokes for the glow, a blue bed, a dark shadow nudged down and right, the bronze body on a gradient, a pale rim nudged up and left, and a dashed grain. The bands repeat a 176 × 22 tile of the same carving as an SVG background sized in --u, over a tile of pale flecks; the screen’s texture is a sparse engraved tile at a few percent.
- The inner glow is a stack of inset box-shadows on a layer over the content: a hairline at the top and bottom edges, a soft spill under each band (stronger at the bottom) and a faint vignette. Once on, it breathes between 100 and 80 percent over 5 s.
- Power-on runs when the frame is first seen and each time powered turns true; the layers involved are keyed by a boot counter so their keyframes replay. A cyan rim is revealed by two conic-gradient masks on a registered angle (@property --tf-sweep), opening from the top centre down both sides in 560 ms, then fades; a black layer lifts off the navy from 180 ms; the title, neighbours and content rise 8 px from about 520 ms; the hints follow 60 ms apart. Power off fades the words and content in 200 ms, then the screen darkens.
- Changing screen keys the labels and content by screen: the new title comes in from the side of travel at 0.8 scale with a small overshoot (320 ms), the neighbours and content slide 32–36 px, and the pressed shoulder glyph squeezes to 0.78 and springs back. Pushing past the first or last screen bumps the bar 7 px instead. A hint’s glyph does the same squeeze when its button is pressed.
- Without children it shows three sample screens (abilities, map, album) and a demo pages through them, presses hints, and powers off and on while it is on screen and untouched; any input hands it to the reader.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | The frame’s accessible name, and the centred title when there are no tabs. |
tabs | TabletTab[] | Abilities, Map, Album (with the sample screens) | The tablet’s screens in order, as { id, label }. The bar shows the current one and its neighbours; the ends do not wrap. |
tab | string | — | The current screen’s id, to control it from outside. |
defaultTab | string | — | The screen it opens on, when not controlled (the first by default). |
onTabChange | (id: string) => void | — | Called when L, R or a neighbour’s button asks for the next or previous screen. |
footerHints | TabletHint[] | ((tab) => TabletHint[]) | — | The button hints, right aligned, as { label, button, onPress? }; button is A, B, X, Y, L, R, ZL, ZR, +, -, LS or RS. A function receives the current screen. |
powered | boolean | true | On or off. Turning it on plays the power-on (it also plays when the frame is first seen); off fades the content and darkens the screen. |
children | ReactNode | ((tab) => ReactNode) | — | The screen’s content, or a function of the current screen. Without it, three sample screens show. |
aspectRatio | string | — | The frame’s aspect ratio, such as "4 / 3". By default 16 / 9, and 4 / 3 under 600 px; content taller than that grows the frame. |
autoplay | boolean | true | While on screen and untouched (and powered is not set), a short demo: screens paged, hints pressed, a power cycle. |
bootWhenSeen | boolean | true | Waits for the frame to be first seen before powering on. false powers it on as it mounts (for a frame drawn clipped or off screen) and turns the demo off. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark for the shadow and halo on the page; by default it follows the page. The device itself stays dark. |
className | string | — | Added to the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode,} from 'react';import { BUTTON_NAME, ButtonGlyph, type TabletButton } from './glyphs';import { Corner, TILES } from './ornament';import { SAMPLE_HINTS, SAMPLE_TABS, SampleScreen } from './samples';import { useAutoplay } from './use-autoplay';import styles from './tablet-frame.module.css';/* * Tablet frame: the device that wraps a game's tablet screens (abilities, map, album…). A deep * navy screen with a faint engraved texture and a glowing blue-cyan inner edge, held in a dark// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId, type SVGProps } from 'react';/* * The bezel's carved ornament, drawn as engraved circuit traces: tubes that run straight, step at * 45 degrees, bend round in small rounded notches and end in ring pads or round studs. * * Every shape is a stroke on one shared geometry, stacked like a relief: two wide, faint blue * strokes for the glow round the carving, a blue bed, a dark shadow pushed down and right, the * bronze body, a pale rim pushed up and left (the light catching the top edge) and a dashed dark * grain. No filter and no bitmap: it is all vector, so it stays sharp at any size. A pad is a * point-sized circle whose round stroke makes a stud; a ring is a circle stroked as a tube. * * The four corners are drawn apart (the bezel is heaviest at the top: a broad piece top left, a * tall one running down the right side, a small one bottom right and low arches bottom left), each * in a box measured in --u, so the CSS places and sizes them. The bands between them repeat a * tile of the same carving (bandTile), set as a background image sized in --u. */type CornerArt = { /** The viewBox's width and height, in --u. */ w: number; h: number;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The controller glyphs the tablet prints in its bars: shoulder buttons as a rounded square with * the letter cut out of it, face and system buttons as a disc, sticks as a disc with a ring and a * small cap. All are drawn in currentColor with the letter in var(--glyph-ink), so the bar decides * their colour; they are decorative (the buttons they sit in carry the name). */export type TabletButton = 'A' | 'B' | 'X' | 'Y' | 'L' | 'R' | 'ZL' | 'ZR' | '+' | '-' | 'LS' | 'RS';const SQUARE = new Set<TabletButton>(['L', 'R', 'ZL', 'ZR']);export function ButtonGlyph({ button }: { button: TabletButton }) { if (SQUARE.has(button)) { const small = button.length === 2; return ( <svg viewBox="0 0 37 30" aria-hidden="true" data-shape="square"> <rect x="0.5" y="0.5" width="36" height="29" rx="5.5" fill="currentColor" /> <text x="18.5" y={small ? 20.4 : 21.4} textAnchor="middle"// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties } from 'react';import type { TabletButton } from './glyphs';import styles from './samples.module.css';/* * The sample screens the frame shows when it is given no children: three of a tablet's pages, in * flat original art, so the frame is seen doing its job. An abilities page (a row of slots, one * picked, its name and what it does), a map page (a faint grid, a route, the player's arrow and * two pins) and an album page (a grid of abstract snapshots, one framed). `beat` moves the * selection on the abilities and album pages, for the demo; a click picks too. */export type SampleTab = { id: string; label: string };export const SAMPLE_TABS: SampleTab[] = [ { id: 'abilities', label: 'Abilities' }, { id: 'map', label: 'Map' }, { id: 'album', label: 'Album' },];/* Tablet frame (tablet-frame.tsx): a game tablet's device frame, centred on a plain stage. Every size is a multiple of --u, 1 px when the frame is 960 px wide; text and the bars have a floor so a phone stays readable. The bezel, the screen and their glow are dark on any page, as a game draws them; only the shadow on the page and the focus ring follow the colour scheme, through light-dark() tokens. The ornament is inline SVG (the corners) and SVG tiles (the bands, the stone, the screen's texture) sized in --u, so nothing is a bitmap and nothing blurs. Motion: the power-on is keyframes on elements keyed by each boot (the sweep, the blackout, the bars' labels, the content, the hints); a screen change replays the labels' and content's keyframes from the side of travel; power off is transitions. */@property --tf-sweep { syntax: '<angle>'; inherits: false; initial-value: 180deg;}.root { /* The screen. */ --navy: #17213d; --navy-deep: #0d1429; --slot: #0b1424;/* The sample screens (samples.tsx), drawn inside the frame's screen. Sizes are multiples of the frame's --u, with a floor on text so it stays readable on a phone; colours come from the frame's tokens (--cyan, --cyan-soft, --text-soft, --slot). *//* Abilities: a centred column. */.abilities { display: grid; justify-items: center; align-content: center; gap: calc(10 * var(--u)); height: 100%; text-align: center;}.abilityName { margin: 0; font-size: max(16px, calc(25 * var(--u))); font-style: italic; font-weight: 600; color: var(--cyan); text-shadow: 0 0 calc(8 * var(--u)) var(--cyan-haze); animation: rise 240ms cubic-bezier(0.2, 0.8, 0.2, 1) both;}.ticks { width: calc(90 * var(--u));// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` and `reduced` are returned too, for * the frame's power-on, which waits until it is first seen. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);- The device is a section named by title (or the current screen); the top bar is a nav whose neighbours are buttons named “Previous screen: Map” and “Next screen: Album”, with aria-keyshortcuts, and the current screen’s name is a heading.
- The screen is a focusable region named by the current screen, so the keys work even when the content has nothing focusable. When a neighbour button disappears at an end, focus moves to the screen instead of being lost.
- Keyboard as a controller: L, Q or Page Up and R, E or Page Down change screen; A, B, X, Y, + and - press the hint with that button (Escape presses B). Keys typed into a field inside the content are left alone.
- A polite live region says the new screen and its position (“Map, screen 2 of 3”).
- The bezel, the glows and the glyphs are aria-hidden; the hints are buttons named by their label, with the button in aria-keyshortcuts. Everything shows a :focus-visible ring.
- Reduced motion: no sweep, no rise, no slides, no bump, no breathing; power on and off and screen changes are immediate. The demo never plays.