Interaction patterns
System-wide behaviors — how state, selection, live updates, and keyboard affordances read consistently across every component.
Last updated
These are behaviors, not components: patterns that must read the same everywhere so the system feels like one product. Each is already expressed in tokens/components — this page is where the rule lives.
Open triggers stay lit
A trigger whose menu or popover is open keeps its hover treatment, so it never drops back to rest
while its surface is showing — the lit trigger is the anchor the eye tracks. Two attributes carry
it: Base UI's data-popup-open (NavigationMenuTrigger, menu sub-triggers, Select) and plain
aria-expanded, which Button's outline, secondary and ghost variants answer with the same
bg-muted step. A Button used as a custom trigger therefore needs nothing extra beyond setting
aria-expanded from your open state.
Highlight-on-change
When a value updates live (a record field synced, a row inserted), pulse it with
motion-flash — a 2px accent ring that swells and settles. Set --vs-flash-color to match the
context (var(--info) for a synced field, the default ring otherwise). Honours
prefers-reduced-motion (collapses to no-op). Use it for events the user should notice, not
on every re-render.
Selection & cell focus
- List/menu selection rides
data-selected/data-highlighted→ the neutralaccentfill (never a hue). - Grid cell focus in
DataGridkeeps the same 2px outline but draws it inside (focus-visible:-outline-offset-2), because the scroll container clips an outward one. Same width, same--ring— it is the one outline, not a second treatment. - Everything else keyboard-focusable gets the one 2px
:focus-visibleoutline; text fields use the border-tint instead (a raw input can't tell mouse from keyboard).
The page doesn't bounce under a mouse
On a mouse or trackpad (pointer: fine), base.css sets overscroll-behavior-y: none on html
and body (INT-12). The page no longer rubber-bands at its top and bottom edges, and scrolling past
the end of an inner scroller (a list, a panel, a table) no longer carries on into the page behind it.
- Vertical only. Horizontal overscroll is untouched, so a trackpad two-finger swipe still goes back and forward.
- Fine pointers only. Touch devices keep the native bounce and pull-to-refresh.
- Nothing to add per component. Don't restate it on a page or a scroller. An inner scroller that
must contain its scroll on touch too still sets
overscroll-contain/overscroll-y-containitself, asCombobox's list andBoard's lanes do.
/* @vegastack/design-tokens/base.css */
@media (pointer: fine) {
html,
body {
overscroll-behavior-y: none;
}
}Keyboard affordances are visible
- Shortcut hints render in
Kbd, andKbdcomposes inside buttons and menu rows (Cancel [ESC],Save [↵],Search records ↵) — the shortcut lives next to the action. - Command palettes and rich pickers can pin a footer hint bar: navigation hints left
(
↑ ↓ Navigate), the primary action right. Upstream'scommandships no footer part — compose one underCommandListwithKbd.
Settings save model
Settings fields autosave. Text saves after a short debounce (about 800ms) and on blur; pickers,
switches and swatches save at once. Each field shows its own quiet status inline
(AutoSaveIndicator variant="icon", or AutoSaveInput), and a failed save rolls the value back
and raises a toast with Retry. Validate inline (a required name, a length limit) before saving.
Consequential changes confirm first — anything that changes who can see something or removes
it (making a space private, archiving, deleting, transferring ownership) opens an AlertDialog
that says what will happen ("3 people who aren't members will lose access") and saves only on its
confirm button, which repeats the verb.
Explicit Discard / Save is the exception: a multi-field draft that must apply together — a
wizard step, a saved view's filter set (the data-grid view bar), a form whose fields only make
sense as a whole. There the user owns when the change becomes everyone's, and an ActionBar shows
"Unsaved changes" with Discard / Save.
Progressive disclosure
- Overflow collapses behind a count:
TagGroup+N,DataList's "View all values" — the collapsed control expands in place, it never navigates away. - In-place drilldown beats cascading flyouts for multi-step pickers (the filter builder): a back chevron swaps the panel content within one popover.
Density has tiers, not one size
Chrome runs tighter than content. Button and its relatives ship four heights — xs 24px, sm 28px,
default 32px, lg 36px — with matching icon-xs / icon-sm / icon / icon-lg squares. Reach
for size="sm", text-xs font-medium labels and text-muted-foreground secondary text on
data-dense surfaces (a toolbar, a filter bar, a joined ToggleGroup); keep the default 32px tier
for primary flows. Do not mix two tiers inside one control group.