Skip to content

Polish — UI micro-interaction + visual-detail discipline

The frontend-polish channel: the codified design-engineering rules that turn an OK interface into one that feels considered. Concentric border radius. Optical alignment. Scale on press. Tabular numbers. Interruptible transitions. The small things that compound.

This is distinct from sibling channels:

  • channels/web.md covers editorial HTML, Marginalia, Pandoc, and static capture — the prose surface.
  • channels/interactive.md covers live demos where the reader moves parameters — the exploratory surface.
  • This channel covers UI polish — the tactile surface that any frontend benefits from regardless of its higher-level purpose.

Structurally mined from thedavidmurray/claude-make-interfaces-feel-better (MIT, archived 2026-05). The 16 rules below are ported and tightened for muriel's brand floor; per-rule citations live inline. Mining stance follows muriel's curator pattern — port the discipline, not the React component code.

Part of the muriel skill — see the top-level index for mission, universal rules, and channel map.

When to use

  • Building or reviewing UI components (buttons, cards, dropdowns, modals)
  • Implementing animations, hover states, shadows, borders, typography micro-details
  • Reviewing AI-generated frontend code for "feels off" without obvious cause
  • Adding tactile feedback (scale on press, hover lift)
  • Closing the gap between "renders correctly" and "feels considered"
  • Triggers: "make it feel better", "polish this", "feels off", "jarring", "loose"

If the work is data viz, use channels/charts.md. If it's a live demo, use channels/interactive.md. If it's prose-rendering HTML, use channels/web.md.

Workflow

  1. Identify the surface. Is it a button, card, list item, modal, navigation? Different surfaces apply different subsets of rules.
  2. Apply universal rules (1–4 below) — they hit nearly every interface.
  3. Apply surface-specific rules from the matching section (Typography / Surfaces / Animations / Performance).
  4. Run the validation checklist before declaring done.
  5. Audit any text element with muriel.contrast if brand tokens are in play — the 8:1 floor still binds; polish is additive, never a contrast excuse.

Universal rules

The four that hit almost every interface. Numbered for citation.

1. Concentric border radius

When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:

outer_radius = inner_radius + padding

Mismatched radii on nested elements is the single most common thing that makes interfaces feel off. If padding exceeds 24px, treat the layers as separate surfaces and choose each radius independently — the strict math stops mattering at that scale.

/* Good — concentric */
.card        { border-radius: 20px; padding: 8px; }  /* 12 + 8 = 20 */
.card-inner  { border-radius: 12px; }

/* Bad — same radius on both */
.card        { border-radius: 12px; padding: 8px; }
.card-inner  { border-radius: 12px; }

2. Optical over geometric alignment

When geometric centering looks off, align optically. Three common cases:

  • Button with text + trailing icon: icon-side padding = text-side padding − 2px so the icon doesn't appear pushed too far out.
  • Play-button triangle: shift 2px right of geometric center — the triangle's visual mass sits left of its bounding box.
  • Asymmetric icons (stars, arrows, carets): fix in the SVG itself when possible; fall back to margin-left: 1px adjustments only if the SVG can't be edited.

3. Shadows over borders for depth

For buttons, cards, and containers using a border to suggest elevation: prefer a three-layer box-shadow over a solid border. Shadows adapt to any background via transparency; solid borders don't survive background changes or image fills.

Exception: dividers (border-b, border-t, side borders for layout separation) and form-input outlines (for focus accessibility) stay as borders. The shadow-over-border rule is for elevation, not separation.

:root {
  --shadow-border:
    0 0 0 1px rgba(0, 0, 0, 0.06),
    0 1px 2px -1px rgba(0, 0, 0, 0.06),
    0 2px 4px 0 rgba(0, 0, 0, 0.04);
  --shadow-border-hover:
    0 0 0 1px rgba(0, 0, 0, 0.08),
    0 1px 2px -1px rgba(0, 0, 0, 0.08),
    0 2px 4px 0 rgba(0, 0, 0, 0.06);
}

/* Dark mode — simplify to a single white ring */
[data-theme="dark"] {
  --shadow-border:       0 0 0 1px rgba(255, 255, 255, 0.08);
  --shadow-border-hover: 0 0 0 1px rgba(255, 255, 255, 0.13);
}

4. Minimum 40×40px hit area

Interactive elements need a hit area of at least 40×40px (WCAG 2.5.5 recommends 44×44; 40 is the minimum muriel accepts). If the visible element is smaller (a 20×20 checkbox), extend with a pseudo-element. Never let two interactive hit areas overlap — shrink the pseudo-element rather than have collisions.

.checkbox {
  position: relative;
  width: 20px;
  height: 20px;
}
.checkbox::after {
  content: "";
  position: absolute;
  inset: -10px;       /* extends to 40×40 */
}

Typography rules

5. text-wrap: balance on headings

Headings and short text blocks (≤6 lines on Chromium, ≤10 on Firefox) get even line distribution with text-wrap: balance. The balancing algorithm is computationally expensive — on body paragraphs it's silently ignored.

h1, h2, h3 { text-wrap: balance; }

6. text-wrap: pretty on body text

For paragraphs longer than the balance threshold, text-wrap: pretty runs a slower algorithm that favors typography over performance. Result: fewer orphans without the line-count cap.

p, .article-body { text-wrap: pretty; }

Decision table:

Scenario Property
Headings, titles, short text (≤6 lines) text-wrap: balance
Body paragraphs, descriptions text-wrap: pretty
Code blocks, pre-formatted text Neither — leave default

7. macOS font smoothing at the root

Apply once on <html>. Other platforms ignore these properties, so the rule is safe to ship universally.

html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

Never apply per-element — inconsistent smoothing reads as heavier text in some places and thinner in others, which is more distracting than no smoothing at all.

8. Tabular numbers for dynamic values

Counters, prices, timers, table columns of numbers, animated number transitions — all use font-variant-numeric: tabular-nums to prevent layout shift as digits change.

.counter, .price, table td.numeric { font-variant-numeric: tabular-nums; }

Don't use tabular-nums on static display numbers, phone numbers, zip codes, version strings, or decorative large numerals — the proportional spacing reads better when the value isn't changing. (Some fonts like Inter widen the 1 glyph under tabular-nums; verify in the project's font before shipping.)

Surface rules

9. Image outlines

Add a subtle 1px inset outline to images. Creates consistent depth across a design system without affecting layout (outlines don't add to box dimensions).

img {
  outline: 1px solid rgba(0, 0, 0, 0.1);
  outline-offset: -1px;
}
[data-theme="dark"] img {
  outline-color: rgba(255, 255, 255, 0.1);
}

Animation rules

10. Interruptible transitions for interactive state, keyframes only for one-shot sequences

CSS transitions interpolate toward the latest state and retarget mid-animation. CSS keyframe animations run on a fixed timeline and restart from the beginning if re-triggered. Pick by intent:

Transitions Keyframes
Interruptible Yes — retargets No — restarts
Use for Interactive state changes (hover, toggle, drawer) One-shot sequences (enter animations, loading spinners)

A drawer using keyframes for open/close snaps on rapid toggling. A drawer using transitions smoothly reverses mid-flight.

11. Split + stagger enter animations

Don't animate a single container. Break enters into semantic chunks and stagger:

  • Title → description → CTA buttons, ~100ms between groups.
  • For hero titles, consider splitting into individual words at ~80ms stagger.
  • Combine opacity + translateY(12px) + filter: blur(4px) for the enter effect.
.stagger-item { opacity: 0; transform: translateY(12px); filter: blur(4px); animation: fadeInUp 400ms ease-out forwards; }
.stagger-item:nth-child(1) { animation-delay:   0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }
@keyframes fadeInUp { to { opacity: 1; transform: translateY(0); filter: blur(0); } }

12. Subtle exit animations

Exits should be quieter than enters — the user's focus is already moving to the next thing. Small fixed translateY(-12px) over 150ms ease-in, not a full-height slide-out. Exception: when spatial context matters (a card returning to a list, a drawer to a screen edge), slide the full distance.

Never remove the exit entirely — popping out of existence loses the user's place.

13. Contextual icon animations — exact values, never deviate

When icons appear/disappear contextually (hover toolbars, state-change toggles), animate with opacity + scale + blur. Use exactly these values:

  • scale: 0.251 (never 0.5, never 0.6)
  • opacity: 01
  • filter: blur(4px)blur(0px)
  • Motion: { type: "spring", duration: 0.3, bounce: 0 }bounce must be 0, never 0.1 or higher

If the project has motion/framer-motion in package.json, use AnimatePresence with these values. If not, keep both icons in the DOM (one absolutely positioned, one in flow) and cross-fade via CSS transitions with cubic-bezier(0.2, 0, 0, 1) — don't add a motion dependency just for icon swaps.

The exact values are tuned: smaller scale values feel jarring, bounce > 0 reads as gimmicky, opacity-only feels lifeless.

14. Scale on press: exactly 0.96, never below 0.95

A subtle scale-down on click gives buttons tactile feedback. Always scale(0.96). Anything below 0.95 reads as exaggerated — past that threshold the button feels like it's collapsing rather than depressing.

.button { transition-property: scale; transition-duration: 150ms; transition-timing-function: ease-out; }
.button:active { scale: 0.96; }

Not every button needs this. Provide a static prop on the button component to disable scale for cases where the motion would be distracting (form submits inside dense layouts, primary-action buttons that already have other feedback).

15. Skip enter animations on page load

For animation systems that fire on mount (Framer Motion's AnimatePresence, etc.), use initial={false} so default-state elements don't animate in on first render. Icons that match the current state on page load shouldn't pop in — only state changes should animate.

Caveat: don't apply this to staged page-enter sequences (rule 11). If initial={false} would skip the entire intentional entrance, the rule doesn't apply.

Performance rules

16. Never transition: all; will-change only for compositor-friendly properties

Two coupled performance rules:

  • transition: all is banned. Always specify exact properties: transition-property: scale, opacity, filter. The shorthand forces the browser to watch every property for changes — causes unintended transitions on color/padding/shadow and prevents browser optimizations. Tailwind's transition shorthand has the same issue; use transition-[scale,opacity,filter] bracket syntax instead. transition-transform is fine — it expands to transform, translate, scale, rotate specifically.
  • will-change only for GPU-compositable propertiestransform, opacity, filter, clip-path. Never will-change: all. Never on background-color, padding, top/left/width/height — they can't be GPU-composited so the hint accomplishes nothing while costing a compositing layer in memory. Only add will-change when you observe first-frame stutter; modern browsers optimize most cases on their own.
Property GPU-compositable? will-change worth it?
transform, translate, scale, rotate Yes Yes
opacity Yes Yes
filter (blur, brightness) Yes Yes
clip-path Yes Yes
background-*, border-*, color No No
top, left, width, height No No

Anti-patterns at a glance

Mistake Fix
Same border radius on parent and child outer = inner + padding
Icons look off-center Optical adjustment (icon-side padding −2px, or fix SVG directly)
Hard 1px borders for elevation Three-layer box-shadow
Jarring enter animations Split into semantic chunks, stagger at ~100ms
Dramatic exit animations Small translateY(-12px), 150ms ease-in
Icon swaps that pop without animation scale 0.25→1 + opacity 0→1 + blur 4px→0, spring bounce: 0
Buttons with no press feedback scale(0.96) on :active, never below 0.95
Numbers that shift layout as they update font-variant-numeric: tabular-nums
Text rendering heavy on macOS -webkit-font-smoothing: antialiased at root
Headings with orphaned words text-wrap: balance
Body paragraphs with orphans text-wrap: pretty
Page-load animations on default-state elements initial={false} on the AnimatePresence boundary
transition: all or transition shorthand transition-property: scale, opacity (or Tailwind bracket syntax)
will-change: all or will-change: background-color Only on transform, opacity, filter; only when stutter is observed
Tiny hit areas on small icon buttons Extend with ::after { inset: -10px } to 40×40 minimum

Validation checklist

Before declaring a UI surface done, walk through these:

  • [ ] Nested rounded elements use concentric radius math
  • [ ] Icons and asymmetric shapes are optically centered, not geometrically
  • [ ] Elevation uses layered box-shadow, not solid borders
  • [ ] Images carry a subtle 1px inset outline
  • [ ] Interactive elements have ≥40×40px hit area (no overlapping hit areas)
  • [ ] Enter animations are split + staggered (~100ms between groups)
  • [ ] Exit animations are subtle (small translateY, shorter duration than enter)
  • [ ] Contextual icon swaps use the canonical scale 0.25→1 + opacity + blur recipe with bounce: 0
  • [ ] Buttons have scale(0.96) on press (with static opt-out where appropriate)
  • [ ] Default-state elements don't animate on page load (initial={false})
  • [ ] No transition: all anywhere — exact properties only
  • [ ] will-change only on compositor-friendly properties, only when first-frame stutter is observed
  • [ ] macOS font smoothing applied once at root
  • [ ] Dynamic numbers use tabular-nums
  • [ ] Headings use text-wrap: balance; body uses text-wrap: pretty

Brand-floor reminder

Polish is additive — it never excuses a contrast violation. Any text element in the polished UI still has to clear muriel's 8:1 floor:

python -m muriel.contrast audit-svg path/to/component.svg
python -m muriel.contrast audit-html path/to/component.html

Hover and focus states are particularly likely to drop below 8:1 — verify both rest and hover states explicitly. Shadows-over-borders (rule 3) interact with this: hover-shadow color must clear floor against whatever sits beneath.

See also

  • channels/web.md — editorial HTML, marginalia, the prose surface where polish rules apply to readable content
  • channels/interactive.md — live demos with parameter sliders, where the polish rules apply to the controls
  • channels/charts.md — data viz, where polish applies to tooltips, axis labels, hover states
  • agents/muriel-critique.md — runs the polish validation checklist as part of critique when artifacts target the app register

Prior art

  • thedavidmurray/claude-make-interfaces-feel-better (MIT, archived May 2026) — Source for all 16 numbered rules above. The mathematical-precision framing (outer = inner + padding, exact 0.96 press value, exact 0.25 icon scale, bounce: 0) is preserved verbatim because the values are tuned, not arbitrary.
  • Material Design 3 Motion — Tangentially related; muriel intentionally does not adopt Material's broader motion vocabulary.
  • Apple HIG — Motion — Read-only reference; cited by paraphrase per scholarly discipline (Apple-proprietary docs).