Wireframe laptop
NewAn isometric hairline laptop that types to itself; keys press under the pointer and a click ripples out.
wireframe-laptop/wireframe-laptop.tsxAn open laptop that types to itself; keys press down under the pointer, and a click ripples out from the key.
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
- A hero or an empty state that should show a product is about typing, coding or writing, without a screenshot.
- A quiet, playful drawing that rewards a hover: keys press under the pointer and a click ripples out.
- A page in a monochrome, technical style, where a hairline drawing fits better than a photo or a 3D model.
- Showing off a theme switch: the drawing follows the page’s
color-schemeand flashes when it changes.
When not to use
- Showing the real product. Use a screenshot or a live demo inside a device frame.
- A text field or any real input: it presses keys, it doesn’t take text. Use a text field.
- Dense pages with several moving things: its own typing and demo pointer pull the eye. Set
typing={false}andautoplay={false}, or leave it out. - Small spaces under about 300 px wide, where the keys are too small to aim at.
You can add this component with the shadcn CLI or by hand:
Into components/wireframe-laptop of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/wireframe-laptop.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 { WireframeLaptop } from './wireframe-laptop';export function Hero() { return ( <WireframeLaptop phrase="shipping on friday. " defaultIntensity={3} onKey={(key) => console.log(key)} autoplay={false} /> );}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.
An open laptop drawn in hairlines, in true isometric projection, that types a phrase by itself. Keys press down under the pointer, a click sends a ripple across its neighbours, and with the drawing focused your own keyboard presses the matching key. An intensity slider sets how hard keys go down; the theme button switches light and dark, and every line draws in heavy for a moment before it settles back to a hairline.
- The laptop is worked out in three dimensions and projected in the 2:1 isometric of technical drawings: a step across goes one unit right and half a unit down, a step towards the front one unit left and half a unit down. Every solid (the deck, the lid tipped back 11° on its hinge, each of the 62 keys) is a rounded rectangle in plan, extruded.
- A solid draws as two lines: its outline, the convex hull of its foot and its top projected, in the grey ink, and the outline of its top in a fainter one, which shows as the crease along its front edges. A fill in the page colour hides what is behind, so the keys paint back to front.
- Each key is an element of its own. Every key has a press from 0 to 1 that eases towards a target each frame, quickly down (25 ms) and a little slower back up (70 ms), so a moving pointer leaves a short wake of keys rising behind it. Only keys that moved are redrawn, their tops lowered towards the deck and their outlines darker and heavier.
- The target is the strongest of what presses a key: the pointer, whose position is projected back onto the keys’ plane and falls off with distance; a key held on the keyboard; the laptop’s own typing; and any ripple, a band travelling out at 9 keys a second. One frame loop runs while anything moves, then stops.
- The intensity (0.2 to 5) sets how deep keys go, how wide the pointer presses and how far a ripple reaches.
- The laptop types its phrase while on screen: a key every 100 to 170 ms, held about 95 ms, and the space bar held longer between words, as one stops to think.
- The line colours and width are registered custom properties (@property), so on a change of scheme one 240 ms animation on the drawing turns every line heavy and dark, holds it, and eases it back to a three-quarter-px hairline.
- Every colour is a light-dark() token, so it follows the page’s color-scheme or the colorScheme prop; the theme button sets its own, read back from a probe so the button names the scheme in use. While untouched, a demo pointer sweeps the keys, clicks one for a ripple and flips the theme.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | 'laptop' | The name in the card’s top left corner. |
caption | string | — | The line under the controls; an empty string for none. |
tags | string[] | ['01 hit', '02 order', '05 rest', '07 cost'] | The small numbered tags under the caption; an empty list for none. |
phrase | string | 'hello world, this laptop types to itself. ' | What the laptop types to itself, again and again. |
typing | boolean | true | The laptop types the phrase by itself while it is on screen. |
defaultPressed | string[] | — | Keys, by their label (“key h”, “space”…), held down from the start; the corner names the last one. Setting it turns the typing and the demo off. |
defaultPointer | string | — | The key, by its label, the pointer rests on from the start: it and its neighbours press down, less with distance. Setting it turns the typing and the demo off. |
intensity | number | — | How hard keys press, from 0.2 to 5, when you control it. |
defaultIntensity | number | 2.6 | The intensity it starts at, when not controlled. |
onIntensityChange | (intensity: number) => void | — | Called as the slider moves. |
onKey | (label: string) => void | — | Called with a key’s name (“key a”, “space”…) when the reader clicks it or presses it on the keyboard. |
onColorSchemeChange | (scheme: 'light' | 'dark') => void | — | Called when the theme button switches the scheme. |
autoplay | boolean | true | While on screen and untouched, a demo pointer sweeps the keys, clicks one and flips the theme. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. The theme button overrides it until it changes. |
className | string | — | Extra class for 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, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { HEIGHT, KEYS, VIEW, deck, keyAt, keySolid, lid,/* Wireframe laptop (wireframe-laptop.tsx): a framed card with the laptop drawn in hairlines, the intensity slider and the theme button under it, then a caption and a row of tags. Near-white by day, near-black at night, and the lines a mid grey for outlines and a faint one for creases. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop or its theme button sets (data-color-scheme). The drawing's line colours and width are registered custom properties, so the flash on a change of scheme can ease them: every path takes them from the drawing, and one animation on the drawing restyles them all. Memory. The card is one layout boundary sized from its width; the drawing is one SVG of about two hundred paths, and only the keys that move are redrawn, one frame at a time. */@property --wl-line { syntax: '<color>'; inherits: true; initial-value: #9c9c9c;}@property --wl-faint { syntax: '<color>'; inherits: true; initial-value: #dadada;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The laptop's drawing, worked out in three dimensions and projected to the page. * * - The projection is the 2:1 isometric of technical drawings: a step along the laptop's width * (x) goes one unit right and half a unit down, a step towards its front edge (y) one unit left * and half a unit down, and a step up (z) one unit up. Lines along x and y slope at 26.57°. * - Every solid is a rounded rectangle in plan, extruded: the deck, each key (whose top is a * little smaller than its foot), and the lid, tipped back on its hinge. A solid draws as two * lines: its outline (the convex hull of its top and its foot, projected) in the ink, and the * outline of its top in the faint ink, which shows as the crease along its front edges. * - Units are those of the drawing's viewBox (1278 × 1023, twice the card's px at its own size of * 640 px), so the numbers below are the sizes the drawing was measured at. */export type Point = [number, number];/** The viewBox: the card, at twice its px. */export const VIEW = { width: 1278, height: 1023 };/** Where the back left corner of the deck's top lands on the page. */const ORIGIN: Point = [521, 511];// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API. The points of the flight are * handed to whoever wants to move along with it; the laptop reads where the pointer is from its * transform instead, each frame, to press the keys under it. The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// 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. It also says whether the component is on * screen (`visible`) and whether the reader asks for less motion (`reduced`), for the work it * does whoever is in charge. */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;- The drawing is one focusable element (role application, named “laptop: type while it has focus, and its keys press down”) with a visible focus ring; with it focused, keys on the keyboard press the matching key and the corner names it. Tab still moves focus on: its key only taps.
- The intensity is a native range input (role slider) with its label and an output showing its value; the theme button says which scheme is on and which it switches to.
- The drawing itself and the demo pointer are decorative (aria-hidden).
- Under reduced motion presses are instant, a click presses only its key, and there is no typing, no flash and no demo.