Make interfaces feel better
Great interfaces are rarely one big thing — they're a pile of small details that
compound. This is the micro-craft layer that sits under the macro art direction
(design-creative-director, design-review) and the screen-level UX (design-ux):
apply it when building or reviewing any rendered HEEx/Tailwind surface.
Adapted from Jakub Krehel's
make-interfaces-feel-betterskill (github.com/jakubkrehel/make-interfaces-feel-better) for emisar's stack: server-rendered HEEx + Tailwind v3.4, no React / framer-motion. Every framer-motion instruction below is translated to Tailwind utilities, CSS, orPhoenix.LiveView.JS.
What the design system already fixes — apply, don't restate
design-system.md §3 is the one home for the values; a polish pass checks the surface against it and cites the section, never a number from memory:
- concentric radius, elevation on dark (surface step + a light ring, no hard borders), and the minimum hit area — §3.3
- tabular numerals on anything that updates in place,
text-balance/text-pretty— §3.2 - the one easing, the shipped
.rise-1…5stagger, named transition properties (nevertransition-all), and the global reduced-motion block — §3.5 - the global focus ring and selection — §3.6
- press feedback (a slight scale-down on the marketing CTA) — the button primitives in §4
Console vs marketing — where motion is allowed
The polish details split by surface. Get this right or you'll fight design-ux:
- Operator console (LiveView): calm. Decoration must mean something
(
design-ux). Animate only to show a real state change (queued→running, a row landing in audit) — never on hover/press for flourish. The static principles always apply here; the motion principles (exits, icon cross-fade) are used sparingly and only when they encode a real change. - Marketing pages (
controllers/marketing_html/**): distinctive. Authored enter/exit motion, press feedback, and staggered reveals are welcome here when the creative direction calls for them — they must still have static meaning and survive reduced motion (which the global block guarantees).
Principles the rule leaves to craft
1. Optical over geometric alignment
When geometric centering looks off, align optically. A play ▸, a chevron, any
asymmetric icon, and icon-plus-label buttons all usually need a manual nudge
(pl-px, an asymmetric px) — trust the eye, not items-center alone.
2. Interruptible animations
Use CSS transitions for interactive state changes — they can be interrupted
mid-flight when the state flips back. Reserve @keyframes for staged sequences that
run once. For LiveView show/hide, use Phoenix.LiveView.JS.transition/show/hide
(CoreComponents already do) rather than hand-rolled toggles.
3. Subtle exit animations (marketing)
Exits should be softer than enters: a small fixed translateY (~6–8px) and a fade,
never collapsing full height. A loud exit feels broken.
4. Contextual icon swaps — cross-fade, don't toggle
When an icon changes (copy→check, menu→close, sun→moon), never flip hidden. We have
no motion library, so use the dependency-free path: keep both icons in the
DOM, one absolute-positioned over the other, and cross-fade with CSS transitions on
opacity, scale, and blur — scale 0.25→1, opacity 0→1, blur 4px→0,
the house easing. This gives both an enter and an exit for free.
5. Font smoothing — already done
-webkit-font-smoothing: antialiased is already on <body> (via the antialiased
class in root.html.heex). Only re-check it if you introduce a new top-level layout.
6. Image outlines
Give images and screenshots a 1px low-opacity outline so they don't bleed into the
surface. On our dark UI that's ring-1 ring-white/10 — pure white at 10%, never a
tinted neutral (zinc/slate). A tinted ring picks up the surface under it and reads as
dirt on the image edge.
7. Don't animate default-state elements
Enter animations belong on genuine entrances, not on things already on screen. In
LiveView remember mount runs twice and re-renders re-trigger CSS animations — so
keep enter-motion off persistent elements and put it only where a row truly arrives
(a stream insert). On static marketing pages, on-load enters are intended; elsewhere
they read as jank.
8. will-change sparingly
Only on transform, opacity, filter — the properties the GPU composites — and
only when you actually see first-frame stutter. Never will-change: all; a permanent
will-change wastes memory.
Common mistakes
| Mistake | Fix |
|---|---|
| Icon/label looks off-center | Nudge optically; don't trust items-center alone (§1) |
hidden-toggling a changing icon |
Cross-fade two stacked icons (§4) |
| Tinted ring on an image | ring-white/10, pure white (§6) |
| Enter animation on a persistent LiveView element | Motion only where a row arrives (§7) |
| A radius, shadow, numeral, easing, or hit-area value from memory | Cite design-system §3 |
| Decorative motion on the console | Remove it — animate only real state change |
Review output format
When reviewing, present changes as Before / After tables grouped by principle —
include every change, not a subset, one diff per row so it scans. Cite the
file:line and the exact property when it isn't obvious from the snippet. Omit a
principle's table entirely if nothing needed to change (no empty tables).
Concentric border radius (design-system §3.3)
| Before | After |
|---|---|
rounded-2xl card + rounded-2xl inner icon tile |
inner → rounded-lg (step down the scale; a child is never larger than its parent) |
Icon swaps
| Before | After |
|---|---|
copy icon hidden-toggled to a check |
both icons stacked, cross-faded |
Checklist
- Every token-level value checked against design-system §3, not memory
- Icons optically centered, not just geometrically
- Interactive state uses transitions;
@keyframesonly for one-shot sequences - Changing icons cross-fade (both in DOM), never
hidden-toggle - Images carry a pure-white
ring-white/10outline - Enter motion only where an element truly arrives; none on persistent elements
-
will-changeonly on transform/opacity/filter, and only if stutter is real - Console motion encodes a real state change; marketing motion has static meaning