Skip to content

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
Docs wayra.dev/components/mockup

Mockup

A frame is a picture of a product, not the product. Everything inside this one is ordinary markup.

Stable
Report

Revenue

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.

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.

Report

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>
Docs wayra.dev/components/mockup

Mockup

A frame is a picture of a product, not the product. Everything inside this one is ordinary markup.

Stable

The 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.

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.

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.

Surface 100

Draft saved a moment ago.

Surface 200

Draft saved a moment ago.

Surface 300

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.

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;
}

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.

  • The frame is decoration, and it says so. It renders a plain <div> with no role and no aria-* of its own, and the two parts that exist purely to be looked at — the dots and the notch — are aria-hidden. A screen reader walking the page hears the content and nothing about the shell around it.
  • title and url are 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-content on 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. Under forced-colors it is re-drawn in CanvasText along 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-motion has nothing to switch off here.

Do

Docs wayra.dev/docs

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

Report

Draft saved a moment ago.

One aria-hidden dots element, painted base-300. Decoration that looks like decoration.

Don't

Report

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.