stageshow
Build faux product UI that looks like a real product, as a still illustration or an animated demo with a cursor. Browser and app windows, dashboards, phones, notifications, terminals, bento grids and cursor walkthroughs, framed, masked and cropped the way a marketing page, feature card or docs hero needs them. Use whenever someone wants a fake or mocked UI, a product demo or walkthrough animation, a hero or feature-card illustration of an app, a product shot in the style of a polished marketing site, or an animated cursor doing things in an interface, even if they never say "faux UI". React and plain CSS.
Install
npx skills add https://github.com/benjitaylor/skills --skill stageshowThe kit, piece by piece
The reference for every part in kit/. SKILL.md has the map; read the entries for the parts a demo uses.
The kit is four tiers, and they are not equal:
- The stage (
stage.tsx,cursors.tsx): the canvas, the script, the cursor, measuring and playback. Every demo is built on it. - Frames (
window.tsx,phone.tsx,ios.tsx,messages.tsx): windows, devices and system surfaces drawn from real geometry. Never draw these by hand: no hand-built window, phone, bubble, notification, status bar or home indicator. - Motion (
motion/): behaviors that have to hold up frame by frame. They bring motion, not a look; style what's inside them as the product does. - Small parts (
parts/,controls/,code.tsx,chart.tsx): quiet starting points. Restyle them to the product, or build the product's own when they don't match it.
Specimen sheets
Catalogs of how each part behaves, one per tile, not compositions to copy. Use the scenes in SKILL.md for composition ideas, and look at any reference at the intended width before borrowing from it.
examples/controls/: Field, Segmented, Toggle, Tabs, Code, Chart, a card dragged between columns withSlot,Scroll, aCamerapush-in, and thearrowcursor with ripple clicks.examples/glass/: the glass theme on a window, a chip and a popover.examples/brand-marks/: the bundled payment and app artwork on light and dark surfaces; sources and licenses are inkit/brands/SOURCES.md.examples/buttons/: every Button variant and size, label and icon changes beside neighbors, a button in a narrow row.examples/cards/: Card padding and nested radius, a flush sectioned card, a sunken group, Divider in a card and a toolbar, Kbd in a field and a menu.examples/kit-extras/: Chip, Swap, Avatar rings, Meter tones, Menu hover, Expand, solid glyphs, BrandMark.examples/parts/: the newer parts (States, Check pending, Meter, Stream, Connector arrows, Popover and Menu, the satellite lock screen).examples/looks/: nine style studies: three invitation identities, then console, finance, canvas, conversation, research and a dimensional transfer receipt.examples/chrome/andexamples/phones/: every window chrome and phone frame in the kit.
The stage
Stage
The canvas.
- Size. Design size and scale-to-fit.
compact: a second design for narrow containers (the composition rule is inframing.md, Small formats and the page). A host wrapper markeddata-ss-widescales the wide design only for a slot sized by height, never for an ordinary tile, which draws 1:1. - Scene state (
initial), changed by the script'sAct(below). - Children are
(state, rect, layout) => JSX.rect(name)measures anydata-target, re-measured after transitions settle.layoutis'wide'or'compact': read it here, not withuseLook(). - Playback.
play:loop,once,hover. Hover settles back with a glide when the pointer leaves. The stage stops and resets unfinished playback offscreen, restarts it on reentry, and preserves a completed once scene. Under reduced motion it showsposter. - The look.
theme,palette,fidelity;tokens={{ ... }}for one stage (see Look tokens);accent;greys="cool"swaps the warm canvas and well for cool ones, for macOS or a blue ground (light theme only). ground: what the scene sits on.orbsis a pale tile lit by a few soft color orbs, so glass has color to take; pass two or three colors to choose them. Leave it out for the plain ground.fill: grows past the design size to fill the container, up to 2×, for a hero or a docs plate; a host wrapper markeddata-ss-filldoes the same.speedplays the script faster (above 1) or slower.keys: where keycaps show:bottom(the default),top,bottom-leftorbottom-right. Keep them clear of anything open, a menu reaching the bottom of a short stage included; film flags an overlap. Takes{ wide, compact }too.hover: targets for stills.specimenmarks a sheet of parts (the kit's chrome, controls, phones sheets): film's composition checks (framing, voids, ladders, insets) stand down.unframed: for a developer who asks for no framing (framing.md, Small formats and the page). Leave outgroundand keep the product whole with room for its shadow; film's bleed and floating checks stand down.- The cursor: see below.
Act
What a script gets.
- Scene:
set,put,snap(patch, scope?)(no transitions, stage-wide or in one target),wait. - Pointer:
move(to, { duration, path, onMove })(pathas drag's;onMove(at, from, t)gets each point, for a scrubbed readout),click({ double: true };{ right: true }for a context menu),cursor,center,place(at once, with no travel: only behind a cut),enter(to)(arrives from past the nearest frame edge, already moving). drag: dips, closes the open hand intograbat the bottom of the press and opens on release; over text or a canvas it selects and keeps its kind.durationsets its length.{ cursor: 'preserve' }retains the current kind through a product-specific drag such as arrow-based event creation or a resize edge.{ path: (progress, from, to) => point }supplies a constrained path in stage coordinates, with eased 0 to 1 progress and endpoints matching the current cursor and target.- Keys and text:
type(35ms per character by default);key(shows keycaps, resolves as the key comes up so the app can react). - Timing:
stagger(items, fn, gap = 60);mark(name)for filming a beat. Start a move without awaiting it to run it alongside other beats. - Measuring:
rect(name, { layout });caret(name, at)(the character a text selection reaches with the pointer atat; call it in a drag'sonMoveso the highlight tracks the I-beam). - Moves travel in a shallow arc, timed by distance. Hovered targets and every target around them get
data-hover; pressed getdata-pressed. Targets can aim inside an element:{ at: 'apply', x: 0.8, y: 0.6 }.
The cursor
cursor={{ at, kind, style }}; at can be { wide, compact } when the compact design needs a different rest point.
- Styles:
real(macOS cursors),large(1.5×),circle(the iPad's glass pointer),touch(a finger on a phone: a dot only where it taps or drags, nothing between),arrow(a solid rounded arrowhead, as illustrated demos draw it; an I-beam only over text). Never the macOS arrow on a phone. - Kinds come from
data-cursoron elements (pointer,ibeam,crosshair,hand,open,grab,wait,resize-ns,resize-ew,resize-nwse,resize-nesw), switching as the cursor crosses their edge. ripple: truesends a soft ring out from each click, for small stages or when clicks must read at a glance.idle(ms, 4000 by default,falseto keep it on) fades a cursor that has rested that long after acting;still: truekeeps it in the reduced-motion poster.- Leave it out of stills unless pointing is the story.
Timing
EASEholds the kit's named curves as bezier arrays:house(moves inside a shot),through(into a cut),settle(out of a cut),enter(a cursor arriving already moving),trip(one long cursor trip),camera(the push-in).easeCss('settle')writes one as a CSScubic-bezier();bezier('house')turns one into a function of time 0 to 1, for motion you drive by hand.clock.now()is the stage's time. It isperformance.now()in a live page and the stepped time when the playground pauses, steps or records, so time anything a script measures with it, neverDate.now().
Frames
Window
Chrome, chosen by props. See examples/chrome/.
- Browser:
url, plusnav(back and forward),tabsandurlShape="pill". - App:
title, centered in a plain bar. With atoolbarit moves left, 14px after the lights, as a unified toolbar;lead(the left toolbar slot) follows it and the toolbar sits on the right.bodytakes props for the content column. - Sidebar app:
sidebarruns full height and holds the traffic lights, the way Mail, Notes, Linear and Slack look.sidebarWidthsets its width (180 by default). Beside asidebar, the title is the content column's heading at the column's 16px inset: pad the content 16px too, so the heading and the first row line up. - Grounds: a plain or browser window's body is the canvas (a quiet gray); a sidebar window's content column is the surface (white, the sidebar on canvas). For a white page in a plain window, give its content
background: var(--ss-surface); keep one window's content one color. - The lights sit in the same spot in every kind, as far from the left edge as their center is from the top, in every combination.
lights:color,grey(glass's default) orwhite(glass, card or hairline over a strong color). chrome="glass": a frosted browser for a colorful ground.chrome="terminal": a dark terminal, one surface,titledim and centered (northwind — zsh). Fill it with<Prompt path="~/app">command</Prompt>lines, plain divs for output (classesok,err,dimfor status color, sparingly) and a<Caret />on the last prompt. Never hand-style a light window as a terminal. Its colors are tokens:--ss-term-path,--ss-term-sigil,--ss-term-ok,--ss-term-err,--ss-term-dim(and--ss-term-bg); set them on the window or the stage to match an accent.chrome="card": one white card, pale dots, a wide pale address bar and a long soft shadow (a payments site's checkout browser).chrome="hairline": a small quiet frame over a 1px rule with the address as bare text, for a window that is a detail, not the subject.recededfades it to 40% when something comes forward.- macOS 26 (Tahoe), each opt-in:
sidebarStyle="floating"(the sidebar as a rounded panel inset 8px, on a 24-radius window),lightsSize="large"(12px lights, 8px apart),barSize="tall"(a 64px icon-and-label toolbar: no fill or rule, 32px controls at the top with labels under them, lights and title level with them). - Several kinds passed: a
chromeother than the native one wins over everything (terminal, glass, card and hairline ignoresidebar,tabs,navandlead); otherwisesidebarwins, thentabs; aurlbeside asidebarputs the address in the content column's bar.
Phone, LockScreen, Notification
A phone from real geometry at any width, its screen laid out in true points. The framing and sizing rules are in framing.md, Phones.
frame:photo(optional caller-owned bezel through--ss-phone-photo-light/--ss-phone-photo-dark; no photograph ships; falls back toprowithout one),pro,silver,bezel,android,soft(the pale, even-banded marketing-page phone, no island),clay,outline,bare.- Screen:
wallpaper(the screen is white by default, black withdark),island,status.WALLPAPERis the default lock screen ground, a soft blue to lilac gradient after the iPhone's own; pass it as the Phone'swallpaperwithdark. - Size it for its text: draw a phone that shows app text at 340 wide or more (about 0.85 of true size), and let it run off the stage rather than shrink it to fit. A bar on the screen sizes to its content with real padding under its last line, never a fixed height its text can overrun.
LockScreenstacks notifications at the bottom as iOS does (iOS 16 and later).size="satellite"for a phone beside a window (no controls, notifications under the clock);clock={false}drops the date and clock.stack="top"puts notifications under the clock, for a phone the stage's bottom edge crops.Notification: always true iOS size (only Live Activities differ) and, as a banner, native width (the screen minus 18pt each side); the phone is sized and cropped around it, never the other way (framing.md, Phones). Bodies wrap to three lines. On an unlocked phone (in an app, on the home screen) passbannerand it drops in at the top under the island, over the app.
Thread, Bubble, Stamp, Receipt, Keyboard
Messages, from the kit only.
Threadpins a conversation to its bottom, glides older messages up when one arrives and puts tails on the last bubble of each run.appisimessage(true iOS bubbles),smsorchat(AI apps: a quiet bubble for the person, plain text for the reply, pair it withStream).Bubble from="me" | "them"rises in from its tail corner.avatarputs a smallAvatarbeside a received bubble for a group chat. Withsource(the composer text's selector or target) it forms out of the input as iMessage sends: clear the field in the same update. Withtypingit holds the dots and grows into the message when typing ends, one object, never a swap.tapback={{ emoji, show }}adds a reaction (its motion is inmotion.md, Phones in motion).Receiptrolls Delivered to Read and folds away withshow={false}; keep it mounted.Keyboard: the iOS 27 keyboard at true points, full width under the composer. Passtypedand it pops each key and follows auto-capitals;shiftforces shift on or off,pressedholds one key down (a letter, orshift,delete,return,space,123);suggestions,dark. Leave it out by default: most message demos read better without it. Show it only when typing is the story.
Motion parts
Collapse, Fly, Toast, Spinner
Collapse: a height that opens and closes so what follows moves with it.reveal="drop"for a card or message, which arrives whole instead of being uncovered by the edge;from="below"for a chat message landing at the foot of a feed;solidfor a surface unlike the ground (a dark log panel on a light window), which would otherwise fade through gray. On close the content dims then dissolves across the whole of the height's travel, never an empty box.Fly: an element carried along a soft arc from the source element's ownrect()to the landing element's ownrect(), never a slot or wrapper around it (a slot's rect makes it snap size on landing)..ss-fly[data-go]styles it in flight, to settle into its resting look before it lands. It is hidden untilgo, so it never doubles what it leaves, unlessheldshows it waiting atfrom(the thing being carried, or a chip budding off a value).- The handoff: hide the source in the set that turns
goon; when it lands, show the real element and hide the flyer in one set (unmount it), thensnap({ go: false })while it is hidden, ready for the next loop. - The value-chip recipe:
fromis the value's own text rect; the chip's child is drawn transparent, its words exactly over the value's (same size and weight, green, with the + in the padding to its left), and the value fades under it. Withheldit buds in where it sits, then ongoits surface fades in during the flight (a keyframe on.ss-fly[data-go]), and the check or the rolling total comes only after it leaves. Toast: a confirmation that rises in (--ss-toast-risesets how far).Spinner: a pending arc.
Parts
Layout and CSS scope
In render code use the third layout argument; in a script use a.layout.
Both are 'wide' or 'compact'. Do not infer layout from a child element's
measured width: that element can be narrow in either composition.
<Stage className="demo"> puts the class on the outer .ss-fit wrapper.
The inner .ss-stage owns data-layout and the design dimensions:
.demo[data-layout="compact"] matches nothing. Scope the scene's other
selectors under .demo too. Measure the host width before choosing the
compact design size; the example dimensions are not defaults.
Explicit replay
Stage has no imperative replay handle. Put a real, accessible Replay button
in the host and remount the demo at that story boundary:
The click explains the return to the starting picture. Keep the wrapper's size
fixed; for a soft restart, hide the scene before changing the key and reveal
the fresh one after. A keyed replay is deliberate: never use changing keys for
ordinary state changes, text changes or variant switches, which should keep
the moving object's identity. Set play="once" for a scene that waits for
Replay after its payoff, with a meaningful poster so reduced motion shows
the same complete still.