Design principles
These are the visual rules for <scxml-view>, <scxml-explorer> and the demos on the website.
They are for contributors and coding agents who change the elements' UI. Two things take
precedence over them: the user's own words (every string can be replaced, see
Custom UI), and the theme tokens (--scxml-*, see
Theming).
Notation over narration#
Text is for names and values: state names, event names, data values. Everything else is carried by shape, a glyph, position or colour. That includes the kind of a thing, its role, its relationships, counts, and whether it is active.
Every glyph still has a text name. The name is the element's accessible name and its tooltip, so screen readers lose nothing.
Shape tells the kind#
The elements use statechart notation, the same as <scxml-view>'s diagram:
- A rounded box is a state.
- A double outline is a final state.
HandH*are shallow and deep history.∥marks a parallel state.
Don't write "compound" or "atomic". A container's number of states is a badge with the layers
glyph. The badge is also the container's "go inside" button. Write 44 with the glyph, not
"44 inside →".
One small, fixed set of glyphs#
The glyphs are Lucide icons (ISC licence): a 24×24 grid, 2px stroke,
round caps and joins, drawn in currentColor. There are at most about a dozen meanings. Each
glyph has exactly one meaning, and both elements share them.
| Lucide glyph | Meaning | Replaces |
|---|---|---|
log-in |
actions on entry | "on entry …" |
log-out |
actions on exit | "on exit …" |
send |
sends an event or message | "send email:onHold" |
logs |
logs | "log on hold" |
diamond |
the transition has a condition (the guard; UML's decision diamond) | "if In('active')" |
workflow |
invokes a child machine | "2 invokes" |
layers |
a container and its number of states; opens it | "44 inside →" |
door-open |
a way out of the focused state (an exit) | "Leaves to … in … · from …" |
corner-down-right |
a way into the focused state (an entry) | "Entered from …" |
locate / locate-fixed |
Follow off / on (a map's "show my location") | a dot |
list / network |
show the focus as a list / as a diagram | "List" / "Diagram" |
funnel |
only active states | an "active" checkbox |
play, pause, step-forward |
playback | "▶ Play", "Step ⏭" |
chevron-down / chevron-right |
fold / unfold a container | |
circle-help |
what a state is about (its description) | |
scan, minus, plus |
fit, zoom out, zoom in |
A new glyph is added to this table first. The table is the only list of glyphs. An icon that means something else in another place is a bug.
How icons are made#
Icons are SVG, built as DOM nodes inside the components: icon() in
packages/scxmljs/src/ui/svg.ts draws one icon from packages/scxmljs/src/ui/icons.ts, where each
icon is its own export of Lucide element and attribute pairs (so a bundle carries only the icons
its element uses).
Icons are never fetched or bundled as files. They are never parsed from markup either, because
the elements must work under Trusted Types and a strict Content-Security-Policy (see
CSP).
Icons are decorative and aria-hidden. The button or element around an icon carries the name.
Sizes: 16px in controls, 14px inline in text.
Event names: the event chip#
An event name is never broken up and never gets a glyph inside it. It is shown the way the system shows code, in IBM Plex Mono, exactly as written, built from two parts the Tinyactors design system already has:
- The name. The namespace is quieter (
--scxml-fg-muted) and the last part is at full strength:email.paymentFailed.thendone. When there isn't room, the namespace is cut from the left (…Failed.done), never the last part. The target of a transition is never cut either. - The event chip. Where an event is something you can click (it sends the event), it sits in the event chip: a pill with a hairline border, like the playground's suggested events. Where it is only mentioned (a transition that can't be sent now, an exit, a label on an arrow), it is plain text, like inline code.
- Its kind, in the state colours. A completion (
done.state.*,done.invoke.*, or any name whose last part isdone) takes the done pair,--scxml-doneon--scxml-done-bg, like a "done" status pill. An error (error,error.*, or any name whose last part iserror) takes the error pair,--scxml-erroron--scxml-error-bg. Plain text takes the colour; a chip takes the tinted pill. A wildcard (carrier.*) keeps its*as text, quieter.
The colour never stands alone: the kind is also in the name itself (done, error, *).
The builders live in packages/scxmljs/src/ui/notation.ts: eventName() for event names (with
eventKind()), actionGlyphs() for what a state does, glyph() for one named glyph. Give the
element around a clickable event name the ev-chip class and it takes the tint. Use them rather
than building names or glyphs by hand.
One place says where you are#
The breadcrumb is the title. Its last place is large, and its parents are small before it. Don't repeat the location in a subtitle or a second header.
Show what happened, don't write it#
The last step is shown in two ways: the transition taken gets the "just happened" colour, and the state entered gets a brief highlight. Screen readers hear the step from the polite live region. No sentences on screen narrate the steps.
Controls are icons with names#
Playback, zoom, view switches and filters are icon buttons. Each has an accessible name and a
tooltip. Numbers and values stay text: ¼×, 0.7 s, 65%.
Colour means one thing each#
This is the explorer's rule (see the header comment of
packages/scxmljs/src/explorer/styles.ts), and it applies to both elements:
- The running colour marks what is active.
- The waiting colour marks what just happened.
- The done and error colours mark completion and error events (and finished or failed machines).
- The accent colour marks keyboard focus.
Never use colour alone. Pair it with a shape or a glyph.
Surfaces#
- Surfaces are solid, with a hairline border. No translucency, no blur.
- Floating controls sit in fixed, logical places: on the bottom edge, inset like the content. Playback is bottom left, view controls are bottom right.
- Panels float over the content, like the panels of a map.
- Controls show only what is useful now, but they don't jump around. Prefer a control that is disabled in place. If a control must appear, it appears at the outer edge, so nothing else moves.
Before and after#
A row in the focus list, before:
paymentFailed ▸
on entry send email:paymentFailed
customer.payment.retry → paying
email.paymentFailed.done → closedAfter:
paymentFailed ⇥ ↗
…payment.retry → paying …paymentFailed.done → closedHere ⇥ and ↗ stand for the log-in and send glyphs: the state has actions on entry, and they
send something. The names of the actions are in the glyphs' tooltips. The namespaces are cut from
the left, never the last part. The last event is a completion: it is shown in the done colour.