---
title: Device
description: A phone, a tablet or a laptop in CSS 3D, chosen by the stylesheet rather than by JavaScript, with a real screen in it.
source: https://adamjurek.com/components/device
---

## Home


The home page is inside one of these. Which one depends on the window you are
reading this in.

<!-- ::start:showcase demo="device" height="560" -->
<!-- ::end:showcase -->

## The machine is chosen by CSS

This is the decision everything else follows from, so it is worth stating on
its own.

**Nothing in this component measures anything.** It renders one screen and
every piece of chrome all three machines could need, and the stylesheet hides
what does not apply. A phone below 720px, a tablet from 720, a laptop from
1080.

The obvious alternative is to read the width and return one of three trees, and
it is wrong in three separate ways:

| What goes wrong | Why |
| --- | --- |
| Nothing renders on the server | There is no window to measure, so SSR emits whichever branch the fallback picked. |
| The first client frame is wrong | The correction happens after hydration, which is a visible flash of the wrong machine. |
| React throws the tree away | If the server guessed and the client disagrees, that is a hydration mismatch, and the recovery is a full client re-render with dead event handlers on the way. |

All three had already happened in this repo, on other components, for exactly
this reason. Four empty `<div>`s and a `display: none` cost less than any of
them, and are correct before a byte of JavaScript arrives.

> [!NOTE] The numbers are a table
> Widths, aspect ratios, bezels, corner radii, tilt and which chrome each
> machine shows all live in `packages/atoms/devices.md`. `pnpm run doctor`
> compiles it to `devices.css` and `device-kinds.ts`. Editing either output is
> a change that gets reverted.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/device.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/device.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Scenes |
| Files | `device.tsx`, `device-kinds.ts` |
| Dependencies | None |
| Also installs | `use-device-kind` |
| Tags | block, 3d, perspective, responsive, no-js, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Device } from "@sushindustries/ui";

export function Example() {
	return (
		<Device title="a machine">
			<p className="p-4 fg-dim m-0 text-sm">The desktop goes here.</p>
		</Device>
	);
}
```

## What you should see

A phone, a tablet or a laptop drawn in 3D, tilted as if you were looking at
it rather than photographing it flat on. Which one you get depends on the
width of the window this page is open in - resize the browser and it changes
at 720px and 1080px, with no flash and no flicker while it does.

## If nothing happens

All three machines are always in the DOM; the stylesheet just hides the ones
that do not apply at the current width. If nothing ever changes as you
resize, the atoms stylesheet is not loaded - this component draws nothing on
its own, `devices.css` is what decides which machine shows. Content passed as
`children` is a real scroll container, so if it looks cut off, give it
content tall enough to actually scroll rather than assuming the frame is
broken.


## Guides


## Choosing one by hand

```tsx
<Device kind="tablet">{...}</Device>
```

Every generated rule is written twice for this: once under its media query for
`.device:not([data-device])`, once as `.device[data-device="tablet"]` with no
query at all. The two selectors tie on specificity, the attribute forms come
last, and so a choice beats a width.

That is what a settings panel writes, and it is how a showcase can put all
three on one page.

<!-- ::start:spacer size="6" rule="true" -->
<!-- ::end:spacer -->

## The 3D, and which element carries what

Three flat elements laid out in space. Two properties do the work and putting
either on the wrong element breaks the illusion in a way that is hard to name
and easy to see.

<!-- ::start:grid min="15rem" gap="4" -->

**`perspective`** goes on the outer stage. One vanishing point for the whole
machine. On the body instead, the body gets its own and the deck stops agreeing
with it about where the viewer is standing.

**`transform-style: preserve-3d`** goes on the body, so the screen inside it
stays in the same space rather than being flattened into a picture of itself.

**`rotateX`** is the tilt, and it is the only reason the perspective has
anything to do. At zero this is an ordinary rectangle and the entire 3D stage
is dead weight.

**`translateZ`** orders the screen and the back panel. Inside `preserve-3d`,
paint order comes from 3D position rather than from the DOM.

<!-- ::end:grid -->

The lens is longer on a laptop (1400px) than on a phone (1100px), which is the
same instinct as a photographer's: the machine should look like it is being
looked at, not like it is being photographed from six inches away.

> [!CAUTION] The back panel must never be clickable
> `.device-back` is `inset: 0` over the whole body and comes *after* the screen
> in the DOM. The only thing keeping it behind is `preserve-3d` honouring its
> `translateZ(-2px)`, and any ancestor that flattens the 3D context - a filter,
> an overflow, a browser quirk - puts it on top, where it silently swallows
> every click on the screen and looks completely fine doing it.
>
> That happened. It is why the panel carries `pointer-events: none` as well.

## The screen is a container

`.device-screen` declares `container-type: inline-size`, so everything inside
sizes against the screen rather than against the window.

That is the difference between the machine being a frame and being a costume. A
tablet chosen on a wide monitor is 40rem of screen inside a 1600px window; a
`@media` query would give its contents a laptop's spacing, which is the exact
thing choosing a tablet was meant to avoid.

```css
.device-desktop {
	padding: clamp(var(--s-3), 3cqi, var(--s-4));
}
```

## It does not animate open

It used to, on scroll. The lid rose as you reached it.

A frame around real controls should not make you wait to reach them, and this
one is full of real controls - folders that open, windows that drag, a dock.
The animation was a thing you sat through before the screen became readable,
and it ran again every time somebody scrolled back up.

## The dock belongs to the screen, not the desktop

The dock is a child of the *screen*, not of the desktop, so anything it opens
is measured against the screen and clipped by the screen. Inside the desktop it
would be measured against a scrolled box and cropped by it, which is a bug that
looks like a rendering glitch and is really a containing block.

## No window controls

Deliberately not three coloured dots. Those are one vendor's furniture, they
mean close, minimise and zoom, and none of those three things can happen to
this. Drawing controls that do nothing is worse than drawing none.

The windows *inside* the screen do have those actions, and they have real
buttons for them. See `DeskWindow`.

## Where this is used

| Where | What |
| --- | --- |
| The home page | with `FolderShelf` on the screen and a `Dock` along the bottom |
| `packages/atoms/devices.md` | the three machines, as a table |
| `packages/atoms/src/devices.css` | generated. The widths, as media queries |
| `packages/ui/src/device-kinds.ts` | generated. The same widths, as values |
| `useDeviceKind` | for code that has to *name* the machine rather than draw it |


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | - | The desktop. It scrolls on its own and chains at the end. |
| `kind?` | `DeviceKind` | - | Which machine to draw. Left off, the stylesheet decides from the width of the window: a phone, a tablet from 720px, a laptop from 1080px. Set it and that choice wins everywhere, which is what a Settings panel writes and what a showcase uses to put all three on one page. |
| `wallpaper?` | `ReactNode` | - | Drawn behind the desktop, and never in the way of a click. |
| `title?` | `string` | - | Shown in the strip at the top of the screen. |
| `toolbar?` | `ReactNode` | - | Also in the strip, right-aligned. A search field, a clock, a count. |
| `dock?` | `ReactNode` | - | Pinned along the bottom of the screen, below the scrolling desktop. |

<!-- /generated:api -->

## Notes

`title` and `toolbar` share one strip: with both unset, no strip renders at
all, rather than an empty bar. `wallpaper` sits behind `children` and is
marked `aria-hidden`, so it never intercepts a click meant for the desktop in
front of it.

`kind` does not change what gets rendered, only which CSS rule wins - every
piece of chrome for every machine is always in the DOM. Leaving it unset is
not "undecided", it means "let the media query decide", which is the only
mode that has no purely-client behaviour to hydrate into.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="device" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Device, Dock, FolderShelf } from "@sushindustries/ui";

export function Home() {
	return (
		<Device
			title="sushindustries"
			wallpaper={<span className="desk-glow" />}
			dock={<Dock tasks={openTasks} onSearch={openSearch} />}
		>
			<FolderShelf entries={FOLDERS} label="Packages" actionsFor={actionsFor} />
		</Device>
	);
}
```

## What this example is not

Not a component that knows what a folder or a dock is. `Device` only offers
the screen, the strip, and the dock slot - `FolderShelf` and `Dock` are
separate components that happen to be the ones this site puts inside it.
