JB logo
CoffeeyOUTUBE
JB's avatarUgandan flag

JB 

Muke Johnbaptist

Go (Golang) expert · Creator of the Grit Framework

Blog
PreviousNext

The Details That Make an App Feel Premium

A field guide to the micro-interactions, motion, loading states, buttons, forms and searchable dropdowns that make an app feel quick, calm and trustworthy — drawn from building Outpost. Five rules before any code, then the patterns with their React, Next.js and Tailwind CSS v4 implementations: buttons that give under the finger, lists that do not jump as they load, a type-to-filter combobox, honest network states, layout stability, and a checklist before you call a screen finished.

A field guide to micro-interactions, motion, loading states, buttons, forms and searchable dropdowns, from building Outpost.

People rarely say an app is well made. They say it feels quick, or calm, or that it "just works". What they are responding to is a few hundred small decisions: a button that gives under the finger, a list that does not jump when it loads, a dropdown that lets you type, an error that says what to do next. None of them is hard on its own. Doing all of them, consistently, is what separates an app people trust from one they tolerate.

This guide is the set of rules we use, with the code. The examples are React, Next.js and Tailwind CSS v4, but the ideas travel to any stack.


1. Five rules before any code

  1. Motion explains, it never decorates. Every animation answers a question: did my click land? (a press), where did that come from? (a dialog rising), is something happening? (a spinner), what changed? (a saved value glowing). If you cannot name the question, remove the animation.
  2. Fast and small. Feedback under 200 ms, transitions 150 to 300 ms, nothing that blocks input. Distances of 4 to 8 px, scales of 0.97 to 1.
  3. One easing curve everywhere. We use cubic-bezier(0.17, 0.55, 0.55, 1): quick out of the gate, soft landing. One curve makes the whole app feel like one hand made it.
  4. Animate only transform and opacity (and colours). Never width, height, top or left: they force layout on every frame and stutter on a cheap phone. Never transition-all: name the properties.
  5. Respect reduced motion. Some people get dizzy from movement. Every animation needs an off switch under prefers-reduced-motion: reduce. The CSS media query does not reach JavaScript animation libraries, so check it in components that animate from script.

Design tokens come first. Colours, radii, shadows and spacing live in CSS variables and Tailwind's @theme. A hard-coded #3b82f6 or p-[13px] is how an app slowly stops looking like itself.

/* globals.css: the tokens everything else reads */
@theme {
  --color-accent: var(--accent);
  --color-accent-fg: var(--accent-fg);
  --radius-control: 0.5rem;
  --radius-card: 0.875rem;
}

2. Buttons

A button has eight states, and most apps design two of them. Default, hover, focus-visible, active (pressed), disabled, loading, error and success. Error and success are the ones people skip.

2.1 The press

A button should give slightly when pressed, the way a physical one does.

@layer base {
  button:not(:disabled),
  a.inline-flex,
  [role="tab"],
  [role="radio"] {
    transition-property:
      color, background-color, border-color, box-shadow, transform, opacity;
    transition-duration: 160ms;
    transition-timing-function: cubic-bezier(0.17, 0.55, 0.55, 1);
  }
  button:not(:disabled):active,
  a.inline-flex:active {
    transform: scale(0.97);
  }
}

Why @layer base: in Tailwind v4, utilities live in a cascade layer, and plain CSS outside any layer beats every layer whatever its specificity. A global rule that says position: relative on buttons would quietly override the fixed on a floating action button. Put global defaults in @layer base so a utility on the element still wins.

2.2 A light sweep on the primary action

The main call to action earns a little more. A highlight crosses it once when the pointer arrives.

@layer base {
  .bg-accent:is(button, a):not(:disabled) {
    position: relative;
    overflow: hidden;
    isolation: isolate;
  }
  .bg-accent:is(button, a):not(:disabled)::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    background: linear-gradient(
      110deg,
      transparent 30%,
      rgb(255 255 255 / 0.28) 50%,
      transparent 70%
    );
    transform: translateX(-120%);
  }
  .bg-accent:is(button, a):not(:disabled):hover::after {
    transform: translateX(120%);
    transition: transform 700ms cubic-bezier(0.17, 0.55, 0.55, 1);
  }
}

Use it on one button per screen. Two shining buttons is no shining buttons.

2.3 The busy state: show the work, do not just grey out

The most common mistake is disabling a button while it saves and calling that a loading state. A greyed button reads as you are not allowed, not I am working. Keep its colour, swap its icon for a spinner, and run a faint sweep across it.

Mark it with aria-busy, which screen readers announce, and style the attribute once for the whole app:

<button
  type="submit"
  aria-busy={save.isPending}
  disabled={save.isPending}
  className="..."
>
  <Save className="h-4 w-4" aria-hidden="true" />
  {save.isPending ? "Saving" : "Save"}
</button>
button[aria-busy="true"] {
  cursor: progress;
  opacity: 1;
}
button[aria-busy="true"]:disabled {
  opacity: 1;
} /* not greyed */
button[aria-busy="true"] > svg:first-child {
  display: none;
} /* the icon steps aside */
button[aria-busy="true"]::before {
  /* for a spinner */
  content: "";
  display: inline-block;
  flex-shrink: 0;
  width: 0.95em;
  height: 0.95em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 9999px;
  animation: busy-spin 0.7s linear infinite;
}
button[aria-busy="true"]:not(:has(> svg:first-child))::before {
  margin-right: 0.45em;
}
button[aria-busy="true"]:not(.ai-button) {
  background-image: linear-gradient(
    110deg,
    transparent 30%,
    color-mix(in srgb, currentColor 16%, transparent) 50%,
    transparent 70%
  );
  background-size: 250% 100%;
  background-repeat: no-repeat;
  animation: busy-sweep 1.3s linear infinite;
}
@keyframes busy-spin {
  to {
    transform: rotate(360deg);
  }
}
@keyframes busy-sweep {
  from {
    background-position: 120% 0;
  }
  to {
    background-position: -120% 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  button[aria-busy="true"]:not(.ai-button) {
    animation: none;
  }
  button[aria-busy="true"]::before {
    animation-duration: 1.6s;
  }
}

The label changes too: Save becomes Saving. Words carry the state for people who cannot see the spinner.

Retrofitting an existing app. We added aria-busy to 195 buttons with a short script: find each <button> whose disabled expression mentions something.isPending, and add aria-busy={something.isPending} to it. A codemod is safer than doing it by hand, and the CSS does the rest.

2.4 Focus that you can see

Never remove the focus outline without replacing it. Keyboard users navigate by it.

:focus-visible {
  transition: outline-offset 120ms ease;
}

Tailwind: focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent.

2.5 Touch targets

44 by 44 pixels on a phone, at least. A 28 px icon button can keep its 28 px look and still have a 44 px hit area: h-11 w-11 md:h-8 md:w-8 gives the phone the big target and the desktop the compact one.


3. Loading states

Pick the loading state by how long and what is coming.

WaitUseWhy
Under 300 msNothingA flash of spinner is worse than none
Content with a known shapeSkeletonThe page does not jump when it arrives
An action (save, send)Busy buttonThe work belongs to the thing clicked
An uploadProgress bar per filePeople need to know how long
Seconds of AI writingShimmer on the targetIt shows where the result will land

3.1 Skeletons that match the real layout

A skeleton is a promise about the shape of the page. If the real rows are 56 px tall with an avatar, the skeleton rows are 56 px tall with a circle. When the data lands, nothing moves.

3.2 Keep the old data while the new loads

When a filter changes, keep showing the previous results, slightly faded, instead of flashing an empty state. With TanStack Query:

useQuery({
  queryKey: ["invoices", filters],
  queryFn,
  placeholderData: keepPreviousData,
});
<ul className={list.isPlaceholderData ? "opacity-60 transition-opacity" : ""}>

3.3 The AI shimmer

While the AI writes into an editor, a soft band crosses the editor. It says something is being written here, which a spinner in a corner does not.

.ai-writing {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(
    100deg,
    transparent 20%,
    color-mix(in srgb, #8b5cf6 14%, transparent) 40%,
    color-mix(in srgb, #3b82f6 14%, transparent) 60%,
    transparent 80%
  );
  background-size: 250% 100%;
  animation: ai-shimmer 1.4s ease-in-out infinite;
}
@keyframes ai-shimmer {
  from {
    background-position: 120% 0;
  }
  to {
    background-position: -120% 0;
  }
}

3.4 An animated gradient border without a wrapper

The AI button wears a slowly turning rainbow edge. Two backgrounds, one clipped to the padding box and one to the border box, and a registered custom property so the angle can animate:

@property --ai-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}
.ai-button {
  background:
    linear-gradient(var(--bg-primary), var(--bg-primary)) padding-box,
    conic-gradient(
        from var(--ai-angle),
        #8b5cf6,
        #3b82f6,
        #14b8a6,
        #f59e0b,
        #ec4899,
        #8b5cf6
      )
      border-box;
  border: 1.5px solid transparent;
  animation: ai-turn 6s linear infinite;
}
@keyframes ai-turn {
  to {
    --ai-angle: 360deg;
  }
}

Use the token's real name. We shipped this with var(--background) once; the variable did not exist, the whole background declaration became invalid, and the border silently vanished.


4. Things that arrive

4.1 Dialogs settle in

.dialog-in {
  animation: dialog-in 260ms cubic-bezier(0.17, 0.55, 0.55, 1) both;
}
@keyframes dialog-in {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.97);
  }
}

Use the same class for popovers, menus and toasts. Use a native <dialog> with showModal(): it traps focus, closes on Escape and returns focus to the opener, for free.

4.2 New rows fade up

A new email or a new page of results fades up into place. Because React keeps existing elements, only the new ones animate.

.row-in {
  animation: row-in 240ms cubic-bezier(0.17, 0.55, 0.55, 1) both;
}
@keyframes row-in {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

4.3 A saved value glows once

After an inline save, the value glows green for a moment and fades. It answers did that save? without a toast.

.saved-flash {
  animation: saved-flash 1.2s ease-out;
}
@keyframes saved-flash {
  from {
    background-color: color-mix(in srgb, var(--success) 25%, transparent);
  }
}

4.4 A countdown you can feel

For a timed warning (the "Still there?" sign-out dialog), a ring that empties is better than a number alone. The ring's length is the time left:

const R = 26,
  C = 2 * Math.PI * R;
<circle
  r={R}
  cx="32"
  cy="32"
  fill="none"
  strokeWidth="5"
  strokeDasharray={C}
  strokeDashoffset={C * (1 - left / total)}
  className={`countdown-ring ${left <= 10 ? "ring-urgent stroke-danger" : "stroke-warning"}`}
/>;
.countdown-ring {
  transition: stroke-dashoffset 1s linear;
}
.ring-urgent {
  animation: ring-urgent 1s ease-in-out infinite;
}
@keyframes ring-urgent {
  50% {
    opacity: 0.55;
  }
}

5. Forms

5.1 Errors next to the field, in words

An error belongs under its field, in plain language, linked with aria-describedby. Email is not an email address beats a red border alone, and both beat a toast that says Validation failed.

5.2 A save bar that appears only when there is something to save

A sticky "Save" bar that is always on screen covers content and nags. Show it, sticky, only while the form is dirty, and say so: Changes not saved yet / Saved. The next email it writes uses this.

<div
  className={`flex items-center gap-3 ${dirty ? "dialog-in shadow-raised sticky bottom-4" : ""}`}
>
  <span aria-live="polite">
    {dirty ? "Changes not saved yet" : saved ? "Saved." : "Everything is saved"}
  </span>
  <button type="submit" disabled={!dirty} aria-busy={save.isPending}>
    Save
  </button>
</div>

5.3 Never nest forms

A small form inside a bigger one (a link box inside an editor inside a template form) is invalid HTML, and the browser submits the outer form: the page reloads and the work is lost. Either use a <div role="group"> and handle Enter yourself, or render the inner form into document.body with a portal and call e.stopPropagation() in its submit handler. React still bubbles synthetic events through portals to the component tree that rendered them.

5.4 Inline editing

Long detail pages are easier to keep current when people can change a value where they read it. The pattern:

  • The value, with a pencil that appears on hover or focus (and is always visible on touch screens, which have no hover).
  • The pencil turns the value into an input in place, selected. Enter saves, Escape restores, a check and a cross for the mouse.
  • Saving shows the busy state; a saved value glows once; an error appears under the input.

The detail that makes it feel solid: the tools must not move the layout. If the pencil takes up space, every value with a pencil sits further left than the values without one, and the column looks broken. Float the tools absolutely beside the value:

<span className="reveal-group relative inline-flex items-center">
  <span>{value}</span>
  <span
    className={`absolute top-1/2 flex -translate-y-1/2 ${alignRight ? "right-full mr-0.5" : "left-full ml-0.5"}`}
  >
    <CopyButton value={value} label="email" />
    <button className="reveal-tool ..." aria-label="Change email">
      <Pencil />
    </button>
  </span>
</span>
.reveal-tool {
  opacity: 0;
  transition: opacity 150ms ease;
}
.reveal-group:hover .reveal-tool,
.reveal-group:focus-within .reveal-tool,
tr:hover .reveal-tool,
.reveal-tool:focus-visible {
  opacity: 1;
}
@media (hover: none) {
  .reveal-tool {
    opacity: 1;
  }
}

Save through a PATCH that changes only the field given, so two people editing different fields of the same record do not overwrite each other.

5.5 Copy buttons on key information

IDs, invoice numbers, emails, phone numbers, tax numbers: anything people paste somewhere else. Show a check and Copied for 1.6 seconds, and announce it:

<button
  onClick={() => navigator.clipboard.writeText(value).then(() => setDone(true))}
  aria-label={done ? `${label} copied` : `Copy ${label}`}
>
  {done ? <Check /> : <Copy />}
  <span className="sr-only" aria-live="polite">
    {done ? "Copied" : ""}
  </span>
</button>

6. The searchable dropdown (combobox)

A native <select> with forty clients in it is a scroll wheel. Every dropdown that can hold more than a handful of options should let people type.

6.1 Make it a drop-in

Our SearchSelect takes exactly what a <select> takes: value, onChange(e) reading e.target.value, and <option>/<optgroup> children. Replacing every native select in the app was then a search and replace, not a rewrite.

6.2 Follow the ARIA pattern

The WAI-ARIA select-only combobox gives you keyboard behaviour people already know:

  • The trigger is role="combobox" with aria-expanded and aria-controls; the list is role="listbox"; each item role="option" with aria-selected.
  • Arrow keys move, Enter and Space choose, Escape closes (and only the list, not the dialog around it: stopPropagation), Tab closes and moves on, typing a letter jumps or opens the search.
  • aria-activedescendant on the focused element points at the highlighted option, so a screen reader follows the arrows.
  • Short lists (under six options) open without a search box. Three choices do not need one.

6.3 Draw it where it cannot be clipped

Render the list in a portal, positioned against the trigger, flipped upwards when there is no room below. Inside a modal <dialog>, portal into the dialog, not document.body: the dialog lives in the browser's top layer, and anything outside it sits underneath its backdrop.

When people search for a client and it is not there, the worst answer is No results and a trip to another page. Offer to add it:

  • The last option reads Add "Mbale Hardware Ltd" as a new client, shown only when nothing matches exactly.
  • If the record needs only a name (a category), create it on the spot and select it.
  • If it needs more (a client's currency, email), open a short form with the typed name already filled in and only the fields needed now. Save, select it, close, and return the person to the form they were filling. The rest can be added on the record's own page later.
<SearchSelect
  value={categoryId}
  onChange={(e) => setCategoryId(e.target.value)}
  createNoun="category"
  onCreate={async (name) => {
    const made = await saveCategory.mutateAsync({ name, monthly_budget: 0 });
    await categories.refetch();
    return made.id; // chosen at once, shown by name until the list has it
  }}
>
  {categories.map((c) => (
    <option key={c.id} value={c.id}>
      {c.name}
    </option>
  ))}
</SearchSelect>

7. Empty, error and success states

  • Empty is a starting point, not a dead end: what will be here, and the one action that fills it. No quotes for Acme yet. Quotes sent to this client show here, with their answer. plus New quote.
  • Error says what happened and what to do, in the user's words. Map status codes to causes. An upload that failed because the server's firewall refused it should not say wrong file type: The server's firewall refused this file. Try again, or tell whoever runs Outpost.
  • Success is usually a quiet change in place (the value glows, the row moves, the label says Saved), not a toast. Toasts are for things that happened somewhere else.

8. Honesty about the network

Many people use apps on slow or patchy connections. An app that hangs without explanation feels broken; one that says your connection is slow feels considerate.

  • Measure the time of small requests (not uploads or AI calls, which are slow by nature), plus a tiny health check every 30 seconds, plus the browser's own estimate where available (navigator.connection.effectiveType).
  • Judge after several samples in a recent window. One slow request is a slow server moment, not a slow connection.
  • Show signal bars only when it matters (two bars or fewer, or offline), with an explanation one click away.
  • A banner once per slow spell, dismissible. Offline gets its own message (changes will not save until you are back), and coming back gets a brief Back online.
function barsFor(ms: number) {
  if (ms < 400) return 4;
  if (ms < 900) return 3;
  if (ms < 2000) return 2;
  return 1;
}

9. Layout stability

Nothing should jump.

  • Reserve the space for things that load (image dimensions, skeleton heights).
  • Float hover tools instead of inserting them (section 5.4).
  • Keep a removed item's space for the length of its exit animation, or remove it without one; never let the list snap.
  • A drag-resizable panel sets its width through a CSS variable and disables pointer events on iframes while dragging (body.is-resizing iframe { pointer-events: none; }), or the drag dies the moment the pointer crosses an embedded email.

10. Checklist before you call a screen finished

  • Every button: hover, focus-visible, active, disabled, busy, and what happens on error
  • No transition-all, no animated width/height/top/left
  • One easing curve, durations under 300 ms for feedback
  • Everything still makes sense with reduced motion on
  • 44 px touch targets on phones; tools visible without hover on touch
  • Dropdowns with more than a few options are searchable; data-backed ones can create
  • Errors under their fields, in words, linked with aria-describedby
  • Empty states with the one action that fills them
  • Loading keeps the layout still (skeletons, kept data, floated tools)
  • No nested forms
  • Tested at 1440 px and 375 px wide, with no sideways scroll
  • Tested on a slow connection and offline
  • Every colour, radius and shadow from a token

Further reading