Mockup
A shell to show a screen inside: a window with a chrome bar, a browser with an address line, or a phone with a notch. It is decoration and nothing else — no hooks, no handlers, no state, and no opinion about what goes in the middle. The frame draws itself with a hairline and nothing else; your markup keeps every prop, every tab stop and every role it already had.
- Stable
- React 19
- Zero JS
- 3 variants
Mockup
A frame is a picture of a product, not the product. Everything inside this one is ordinary markup.
StableRevenue
48.2K MXN this month, up 12% on July.
Code
<Mockup title="Report"><h3>Revenue</h3><p>48.2K MXN this month, up 12% on July.</p><div style={{ display: 'flex', gap: '0.5rem' }}> <Button size="sm">Export</Button> <Button variant="ghost" size="sm">Filters</Button></div></Mockup><div class="wayra-mockup" data-variant="window" data-size="md" data-surface="100"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Report</span> </div> <div class="wayra-mockup-content"> <h3 style="margin: 0 0 0.25rem; font-size: 1rem">Revenue</h3> <p style="margin: 0 0 0.75rem; font-size: 0.875rem">48.2K MXN this month, up 12% on July.</p> <div style="display: flex; gap: 0.5rem"> <button class="wayra-button" data-variant="filled" data-size="sm">Export</button> <button class="wayra-button" data-variant="ghost" data-size="sm">Filters</button> </div> </div></div>Children go straight into the content area. There are no slot subcomponents and no
figure or image prop — a frame that shipped its own picture could only ever produce
one arrangement, and what people actually want inside a device frame is the real
component they just built. A heading, a table, a form, a whole route: whatever you put
there is untouched markup with its own tab stops.
The frame contributes exactly three things of its own: the shell, the chrome the variant calls for, and the surface the content sits on. It contributes no layout — the content area is a plain box, so the column, the grid or the padding inside it is yours the same way it is inside a Card.
Variants
Section titled “Variants”variant picks the shell and reaches the DOM as data-variant. It is also what decides
which chrome parts exist at all, so a prop that the current variant has no place for is
ignored rather than rendered somewhere odd.
Window
Section titled “Window”Revenue
48.2K MXN this month, up 12% on July.
The default. A chrome bar with the dots and an optional title, and content underneath.
Code
<Mockup variant="window" title="Report">…</Mockup><div class="wayra-mockup" data-variant="window" data-size="md" data-surface="100"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Report</span> </div> <div class="wayra-mockup-content"> <h3 style="margin: 0 0 0.25rem; font-size: 1rem">Revenue</h3> <p style="margin: 0 0 0.75rem; font-size: 0.875rem">48.2K MXN this month, up 12% on July.</p> <div style="display: flex; gap: 0.5rem"> <button class="wayra-button" data-variant="filled" data-size="sm">Export</button> <button class="wayra-button" data-variant="ghost" data-size="sm">Filters</button> </div> </div></div>Browser
Section titled “Browser”Mockup
A frame is a picture of a product, not the product. Everything inside this one is ordinary markup.
StableThe same bar plus the address slot. `url` is rendered under this variant and no other.
Code
<Mockup variant="browser" title="Docs" url="wayra.dev/components/mockup"><h3>Mockup</h3><p>A frame is a picture of a product, not the product.</p><Badge variant="outline" color="success">Stable</Badge></Mockup><div class="wayra-mockup" data-variant="browser" data-size="md" data-surface="100"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Docs</span> <span class="wayra-mockup-url">wayra.dev/components/mockup</span> </div> <div class="wayra-mockup-content"> <h3 style="margin: 0 0 0.25rem; font-size: 1rem">Mockup</h3> <p style="margin: 0 0 0.75rem; font-size: 0.875rem">A frame is a picture of a product, not the product. Everything inside this one is ordinary markup.</p> <span class="wayra-badge" data-variant="outline" data-color="success">Stable</span> </div></div>- Wi-Fi Casa
- Bluetooth On
- Battery 82%
No bar at all: a notch, a much rounder corner, and a frame with a fixed aspect ratio.
Code
<Mockup variant="phone"><List aria-label="Settings" size="sm"> <ListItem title="Wi-Fi" trailing="Casa" /> <ListItem title="Bluetooth" trailing="On" /> <ListItem title="Battery" trailing="82%" /></List></Mockup><div class="wayra-mockup" data-variant="phone" data-size="md" data-surface="100"> <span class="wayra-mockup-notch" aria-hidden="true"></span> <div class="wayra-mockup-content"> <ul class="wayra-list" aria-label="Settings" data-variant="plain" data-size="sm"> <li class="wayra-list-item" data-color="primary"> <span class="wayra-list-body"><span class="wayra-list-title">Wi-Fi</span></span> <span class="wayra-list-trailing">Casa</span> </li> <li class="wayra-list-item" data-color="primary"> <span class="wayra-list-body"><span class="wayra-list-title">Bluetooth</span></span> <span class="wayra-list-trailing">On</span> </li> <li class="wayra-list-item" data-color="primary"> <span class="wayra-list-body"><span class="wayra-list-title">Battery</span></span> <span class="wayra-list-trailing">82%</span> </li> </ul> </div></div>title and url are both optional, and both are dropped from the DOM when you leave them
out — so a bare <Mockup /> is a shell with the dots in it and nothing else. url under
window or phone is ignored instead of failing, which keeps a variant switch from
becoming a props rewrite.
What the frame actually draws
Section titled “What the frame actually draws”Flat, not glass. The shell is an opaque surface drawn by its own hairline frame line.
It carried a lit top edge and a layered drop shadow until 2026-08-29, when every floating
surface in the library went flat; there has never been translucency or a
backdrop-filter anywhere in it. A frame that
blurred the page behind it would put whatever the page happens to be showing inside the
device, which is the opposite of the point.
One object, not two boxes. The chrome bar’s hairline continues the frame’s own border rather than starting a second rectangle, so a window reads as one solid thing with a bar across the top instead of a bar stacked on a box. It is the detail that makes the shell look drawn rather than assembled.
The dots are neutral, and there is one of them. They are painted base-300, not the
red-amber-green of a real title bar, and they are a single aria-hidden element rather
than three spans. That is a deliberate refusal: coloured window controls read as buttons,
and these close nothing. A device frame is a picture of a product, and a picture does not
need working controls.
Only the phone clips
Section titled “Only the phone clips”This is the part that is not obvious from the anatomy. window and browser do not
clip their content, so a Dropdown, a
Tooltip or a Select opened inside one
escapes the frame exactly as it would escape the panel it lives in in the real app. You
are looking at the true behaviour of the thing you are demoing, not at a cropped version
of it.
The phone variant is the exception, and it has to be: a rounded slab whose content
leaked past its own corners would not read as a phone at all. Put the case that needs an
overflowing surface in a window or a browser; put the case that needs a hard edge in
the phone.
size reaches the DOM as data-size and moves one scalar the frame is measured from.
On phone that scalar is the frame’s own inline size — the value
--wayra-mockup-width resolves to — which is why the three below are visibly different
objects:
Draft saved a moment ago.
Draft saved a moment ago.
Draft saved a moment ago.
Code
<Mockup variant="phone" size="xs">…</Mockup><Mockup variant="phone" size="sm">…</Mockup><Mockup variant="phone" size="md">…</Mockup><div class="wayra-mockup" data-variant="phone" data-size="xs" data-surface="100"> <span class="wayra-mockup-notch" aria-hidden="true"></span> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div><div class="wayra-mockup" data-variant="phone" data-size="sm" data-surface="100"> <span class="wayra-mockup-notch" aria-hidden="true"></span> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div><div class="wayra-mockup" data-variant="phone" data-size="md" data-surface="100"> <span class="wayra-mockup-notch" aria-hidden="true"></span> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div>window and browser take a different deal: their --wayra-mockup-width is 100%, so
the container decides how wide the frame is and the step is spent on the shell itself.
Give one a column to grow into — a grid cell, a max-inline-size, the 24rem block every
window example on this page sits in — rather than a size and a hope.
Surfaces
Section titled “Surfaces”Draft saved a moment ago.
Draft saved a moment ago.
Draft saved a moment ago.
Code
<Mockup surface="100">…</Mockup><Mockup surface="200">…</Mockup><Mockup surface="300">…</Mockup><div class="wayra-mockup" data-variant="window" data-size="md" data-surface="100"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Surface 100</span> </div> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div><div class="wayra-mockup" data-variant="window" data-size="md" data-surface="200"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Surface 200</span> </div> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div><div class="wayra-mockup" data-variant="window" data-size="md" data-surface="300"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Surface 300</span> </div> <div class="wayra-mockup-content"> <p style="margin: 0; font-size: 0.8125rem">Draft saved a moment ago.</p> </div></div>surface picks which base step paints the content area — not the shell — and reaches
the DOM as data-surface. 100 is the resting surface, the same colour the page itself
is on, so a mockup at 100 reads as a window onto the page. 200 and 300 step away
from it, which is what you want when the screen inside the frame is a chrome-heavy app
rather than a document.
Away from it, not “darker”. The base scale inverts between the schemes, so a mockup at
200 is correct in light and in dark and in all seven themes — see the same argument, at
length, on the Card page. It is also the reason to reach
for surface before reaching for a background of your own: a hand-picked grey is right
in one scheme and wrong in the other.
Styling contract
Section titled “Styling contract”The class plus the data-* attributes are public API, and the stylesheet is written
against them. Every preview on this page is hand-authored HTML with no React in it:
<div class="wayra-mockup" data-variant="browser" data-size="md" data-surface="100"> <div class="wayra-mockup-bar"> <span class="wayra-mockup-dots" aria-hidden="true"></span> <span class="wayra-mockup-title">Docs</span> <span class="wayra-mockup-url">wayra.dev/components/mockup</span> </div> <div class="wayra-mockup-content">…</div></div>The phone shell is the same root with the bar swapped for a notch:
<div class="wayra-mockup" data-variant="phone" data-size="md" data-surface="100"> <span class="wayra-mockup-notch" aria-hidden="true"></span> <div class="wayra-mockup-content">…</div></div>data-variant, data-size and data-surface are all optional in hand-written markup —
the base rule already draws a medium window on the resting surface, so a bare
<div class="wayra-mockup"> with a content div inside it is a complete frame. The
attributes only matter for the values that override the default.
Three custom properties are yours:
| Property | Default | What it does |
|---|---|---|
--wayra-mockup-width |
100% under window and browser; the data-size step under phone |
The frame’s inline size. Set it to pin a window to a fixed width, or to give a phone a size the ladder does not have. |
--wayra-mockup-aspect |
9 / 19.5 |
The phone frame’s aspect ratio. window and browser never read it — their height comes from what you put in them. |
--wayra-mockup-radius |
var(--wayra-radius-box), which the phone variant re-points much rounder |
The frame’s corner radius. |
Set them on the instance, which is what keeps them out of the token layer:
.hero-frame { --wayra-mockup-width: 48rem; --wayra-mockup-radius: 1rem;}API reference
Section titled “API reference”MockupProps extends ComponentProps<"div"> minus title — the name is taken by the
chrome slot below — so id, style, ref, className, role, aria-* and every
data-* land on the frame, except the three the props own: data-variant, data-size
and data-surface are written after the spread and a raw attribute cannot contradict
them.
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
"window" | "browser" | "phone" |
"window" |
Which shell to draw. Emitted as data-variant. |
size |
WayraSize |
"md" |
Size step: xs, sm, md, lg, xl. Emitted as data-size. |
surface |
"100" | "200" | "300" |
"100" |
Which base step paints the content area. Emitted as data-surface. |
title |
ReactNode |
— | Caption in the chrome bar. Rendered under window and browser; phone has no bar. |
url |
ReactNode |
— | Address text in the browser chrome. Rendered under browser only; ignored elsewhere. |
children |
ReactNode |
— | The screen. Goes in the content area untouched. |
unstyled |
boolean |
false |
Drops wayra-mockup and every part class, keeps every data-*. |
That is the whole surface. There is no color: a device frame carries no semantic role,
so it is absent from the data-color map in styles/color.css and a data-color on one
does nothing. There is
no shape either — the corner is the variant’s job, and
--wayra-mockup-radius is the escape hatch when it is not.
Accessibility
Section titled “Accessibility”- The frame is decoration, and it says so. It renders a plain
<div>with noroleand noaria-*of its own, and the two parts that exist purely to be looked at — the dots and the notch — arearia-hidden. A screen reader walking the page hears the content and nothing about the shell around it. titleandurlare real text. They are the two parts that are not hidden, because a window caption and an address are information, and they are announced in DOM order before the content. Leave them out and nothing is announced in their place.- The address bar is never an
<input>. It is a<span>of plain text. A focusable control that does nothing is an accessibility defect twice over: it takes a tab stop from a page that gains nothing by it, and it invites a keyboard user to type an address that goes nowhere. If a demo genuinely needs a working URL bar, that is an Input you wire up, placed in the content area where it belongs. - Nothing the frame draws is focusable. It adds no tab stops at all, so the tab order through a page full of mockups is exactly the tab order of the screens inside them.
- Content keeps its own semantics. Children are not wrapped in anything that changes
their meaning and not cloned, so a heading inside a mockup still continues the page’s
outline. A frame does not restart the document’s heading levels, which is worth
remembering when the screen inside it opens with an
h1. - Contrast is the page’s contrast. The content area paints
base-contenton the base step you chose, both halves from the theme, so the text inside a mockup measures exactly what the same text measures outside one. The shell’s hairline is deliberately faint and carries no contrast requirement: WCAG 1.4.11 covers boundaries that identify a control, and a decorative frame identifies none. Underforced-colorsit is re-drawn inCanvasTextalong with the dots and the notch, so the shell survives where the fills do not. - Nothing moves. The component has no state, so there is no transition to disable and
prefers-reduced-motionhas nothing to switch off here.
Do and don’t
Section titled “Do and don’t”Do
Draft saved a moment ago.
The address is a of plain text. It reads as a browser and takes no tab stop.
Don't
Draft saved a moment ago.
A real in the chrome: a tab stop that accepts typing and navigates nowhere.
Do
Draft saved a moment ago.
One aria-hidden dots element, painted base-300. Decoration that looks like decoration.
Don't
Draft saved a moment ago.
Three hand-drawn traffic lights. They read as buttons, they close nothing, and the colours are literals outside the token system.