Book a callGitHub1

Craft02/04

UX patterns, compared

Updated · Edit on GitHub

Each pattern on this page is shown twice: once in a way that works against people, and once in a way that works for them. The rule comes first, then the reason, then where it comes from. Many of the drawings are live, so hover over them, press them or tab through them.

Every rule here is an OpenDesigner house standard or one of its accessibility floors, and OpenDesigner applies them in every project. Most come from Emil Kowalski's writing on building Sonner, his toast library, and Vaul, his drawer library, and from his emilkowalski/skills repo. OpenDesigner is not affiliated with him.

Toasts: where they sit and how long they stay

A toast is a short message that appears for a moment and then goes away on its own, such as "Link copied".

  • Put toasts in one corner, bottom-right by default. Choose only from six places: the four corners and the top or bottom center. (STD-components-toasts-drawers-29)
  • Keep them 32 px from the screen edges, or 16 px on screens under 600 px wide. (STD-components-toasts-drawers-30)
  • Show three at most, stacked with depth. Each toast behind the front one sits 14 px higher and 5% smaller. Hovering the stack spreads it out with 14 px between toasts, and the gaps stay part of the hover area so the stack does not collapse as the pointer crosses them. The stack moves with Sonner's own timing, transform 400ms ease. That is slower than most interface motion, which the standards allow because it fits the component. (STD-easing-duration-13, STD-components-toasts-drawers-14, STD-components-toasts-drawers-10, STD-components-toasts-drawers-12, STD-components-toasts-drawers-13)
  • Close a toast after 4 seconds, but pause the timer while the pointer is over it or the tab is hidden. Otherwise people lose a message they are reading, or one that arrived while they were elsewhere. (STD-components-toasts-drawers-15, STD-components-toasts-drawers-16, STD-components-toasts-drawers-17)
  • A toast with an action, such as Undo or Retry, stays until the person uses it or closes it. A timer would put a time limit on the action. The standard bases this on WCAG's Timing Adjustable rule and marks it as OpenDesigner's own inference. (STD-components-toasts-drawers-15)

Every toast on screen, and an Undo on a timer

Five messages cover the page. The one with an action closes itself, so a slow reader loses the chance to undo.

Message archivedUndocloses in 4 s
Draft saved
2 files uploaded
Invite sent
Settings updated

Three at most, stacked, and Undo waits for you

Toasts stack in one corner with depth. Hover or focus the stack to spread it out. The toast with an action stays until you use it or close it.

Message archived
bottom-right · 32 px from the edges, 16 px under 600 px widegap 14 px · 400 ms ease, Sonner's timing
Two ways to show five quick messages. On the left, every toast is on screen and the one with Undo closes after 4 seconds. On the right, at most three stack in the bottom-right corner, each one behind lifted 14 px and scaled down by 0.05, and the toast with an action stays until the person acts.

Source: the Sonner docs and Emil Kowalski's essay Building a toast component.

Swipe to dismiss: speed counts, not only distance

Dismiss when the swipe goes far enough or fast enough. Speed here is the distance moved divided by the milliseconds since the drag began. Above 0.11, the element goes, however short the swipe. People should not have to drag past a fixed point. (STD-springs-gestures-21)

Let people swipe it back the way it came. The allowed swipe directions follow the toast's position, so the swipe heads back toward the edge it entered from. (STD-components-toasts-drawers-18, STD-enter-exit-origin-06)

Only distance counts

A quick, short flick never reaches the line, so the toast springs back. People have to drag all the way past the line.

quick flick, short of the linesprings back
const d = Math.abs(swipeAmount);
if (d >= SWIPE_THRESHOLD)
  dismiss();

Distance or speed

The same quick flick is fast enough, so the toast leaves along the way it was pushed. Dragging past the line still works too.

the same quick flickdismissed
const d = Math.abs(swipeAmount);
const v = d / timeTaken;
if (d >= SWIPE_THRESHOLD
    || v > 0.11) dismiss();
Two toasts flicked to the right, quickly and only a short way. Judged by distance alone, the flick falls short of the dashed line and the toast springs back. Judged by distance or speed, where speed is the distance divided by the milliseconds since the drag began, a speed over 0.11 dismisses it.

Source: Emil Kowalski's Building a toast component, where the 0.11 threshold was found by trial and error, and Building a drawer component.

No animation on keyboard actions

Never animate an action started from the keyboard. That covers shortcuts, opening a command menu (a search box of actions, often opened with Cmd K) and moving a highlight with the arrow keys. People repeat these hundreds of times a day, and any motion makes the interface lag behind their fingers. (STD-when-to-animate-06)

Give no animation to anything used 100 or more times a day, such as a launcher or a command menu. (STD-when-to-animate-05) When a review finds one, delete the animation before trying to tune it. (STD-when-to-animate-17)

The highlight glides after each key

Watch the key and the highlight. Every press starts a 200 ms slide, so the highlight trails behind your fingers.

transition: transform 200ms on each key

The highlight moves with the key

No transition at all. The highlight is on the next row in the same frame as the key press.

no transition: 0 ms
The ring on the arrow key marks each press, four presses 300 ms apart.Reduced motion is on, so both highlights rest on the last row.
A command menu moved through with the arrow key. With a short slide on each press, the highlight lags behind the keys; with no transition it lands on the next row in the same frame. Anything started from the keyboard, and anything used a hundred times a day, should not animate.

Source: Emil Kowalski's You don't need animations and the animate and review-animations skills in emilkowalski/skills.

Tooltips: only the first one waits

Delay the first tooltip, then open its neighbors at once. The delay stops tooltips from popping up when the pointer only passes by. Once one is open, the next opens with no delay and no animation, which makes the whole toolbar feel faster. In Base UI this is transition-duration: 0ms on tooltips marked [data-instant]. (STD-enter-exit-origin-10)

A tooltip scales out of its trigger, from scale(0.97) and opacity 0, over 125 ms on the house ease-out curve. (STD-enter-exit-origin-09)

Every tooltip waits

Point at one button, wait, then slide along the row. Each tooltip makes you wait again, so reading the toolbar is slow.

each: waits, then 125 ms in

Try it with a mouse, or with the Tab key.

Only the first one waits

Point at one button, wait, then slide along the row. Once one tooltip is open, the next opens with no delay and no animation.

first: waits, then 125 ms in · next: 0 ms

Try it with a mouse, or with the Tab key.

Two real toolbars. In the first, every tooltip waits before it appears. In the second, only the first one waits, to avoid opening by accident; after that, neighboring tooltips open with no delay and no animation, so the whole toolbar feels faster.

The demo waits 500 ms before the first tooltip. The standards do not fix the length of that delay.

Source: Emil Kowalski's 7 practical animation tips and the animate recipes in emilkowalski/skills.

Errors: next to the field, before submit

Check form input inline, not on submit. Feedback should come as soon as possible, so the interface feels like it is listening. (STD-visual-details-49) In the drawing, the message sits under the field it is about.

Never show an error with color alone. Add an icon or words, so people who cannot tell the colors apart still see it. This is one of the accessibility floors.

An alert after you press the button

The mistake shows up only on submit, in a dialog that covers the form and does not say which field is wrong.

A message under the field, as you go

The field is checked inline, not on submit. The message sits next to the problem and uses an icon as well as color.

The same sign-up mistake shown two ways. An alert after submit hides the form and names no field. An inline check before submit puts the message right under the problem, with an icon so it does not rely on color alone.

Source: Emil Kowalski's 7 practical animation tips and the apple-design skill in emilkowalski/skills.

Undo instead of "Are you sure?"

Offer Undo for slips, and ask for confirmation only before destructive actions that cannot be undone. Confirmation dialogs used too often train people to click straight through them. (STD-visual-details-40)

For a destructive action, a hold-to-confirm button is one option: see Show the press. (STD-components-toasts-drawers-77)

“Are you sure?” for something you can undo

Archiving can be reversed, yet it asks every time. Asked that often, people learn to click through without reading.

Do it, then offer Undo

The conversation is archived at once. A toast offers Undo and stays until the person uses it or closes it.

Archiving a conversation, a slip that can be reversed. A confirmation dialog every time trains people to click through it. Doing it at once and offering Undo in a toast that waits for the person is faster and safer; keep confirmation for actions that cannot be undone.

Source: the apple-design skill in emilkowalski/skills.

Drawers on phones

On a phone, show secondary content in a drawer from the bottom edge instead of a centered dialog. A drawer, also called a bottom sheet, feels more native on mobile. (STD-components-toasts-drawers-37)

Open it on the drawer curve. A web drawer moves from translateY(100%) to its place over 500 ms on cubic-bezier(0.32, 0.72, 0, 1). The curve comes from the Ionic Framework and closely matches the iOS sheet. Once a finger drags the drawer, a spring takes over. (STD-enter-exit-origin-41, STD-components-toasts-drawers-40)

Never trap people. A drawer closes with a drag down, a tap outside or the Escape key. If one must stay open, give it a visible close button. (STD-accessibility-motion-24, STD-components-toasts-drawers-39)

A desktop dialog on a phone

A small box floats in the middle of the screen, the way it would on a desktop. On a phone it does not feel native.

A drawer from the bottom edge

It rises from the bottom edge on the iOS-like drawer curve. A drag down, a tap outside or Escape closes it.

500 ms · cubic-bezier(0.32, 0.72, 0, 1)
Reduced motion is on, so the drawer stays open.
Secondary content on a phone, shown two ways. A centered desktop-style dialog feels out of place. A bottom drawer rises from the edge in 500 ms on the drawer curve, which closely matches the iOS sheet, and can be closed by dragging, tapping outside or pressing Escape.

Source: Emil Kowalski's Building a drawer component and the Vaul docs. Vaul's README says the library is no longer maintained, so OpenDesigner treats its drawer craft as guidance and says so before suggesting it as a dependency.

Hover only where there is a mouse

Put every hover style inside @media (hover: hover) and (pointer: fine). Touch screens have no hover, so the browser fakes one: the first tap applies the hover style and leaves it there until you tap somewhere else. The press style, :active, is a real press on touch, so it stays outside the query. (STD-accessibility-motion-15)

Detect touch by what the device can do, not by guessing from its name or screen width. Laptops have touch screens and tablets have trackpads. (STD-mobile-touch-02)

Hover styles for every device

Phones have no hover, so the browser fakes one: the first tap applies the hover style and leaves it there until you tap somewhere else.

tapped once: the row stays lit
.row:hover {
  background: var(--accent-soft);
}

Hover only where there is a fine pointer

The hover style applies only when the main input can hover and point precisely. The press style is a real press on touch, so it stays ungated.

@media (hover: hover)
   and (pointer: fine) {
  .row:hover {
    background: var(--accent-soft);
  }
}

.row:active {
  transform: scale(0.97);
}
A hover style on a phone sticks after the first tap, because touch screens fake a hover. Gating every hover rule behind hover: hover and pointer: fine keeps it for mice and trackpads, while the :active press style stays for everyone.

Source: the mobile-native and animate skills in emilkowalski/skills.

Loading spinners

No house standard chooses between skeleton screens and spinners, so this guide does not either. For spinners there are two rules. Make them spin fast, because a fast spinner makes the same wait feel shorter. And spin at a constant speed, with linear easing. (STD-easing-duration-15, STD-easing-duration-01)

Where these rules come from

The standards cited here are listed with their sources in the standards file. Who OpenDesigner learns from describes every source and how one becomes a standard. For the motion behind these patterns, see Motion that feels right.