Make Interfaces Feel Better
Design-engineering details that compound into polished interfaces. Model-invoked skill with references on design direction (aesthetic identity, typeface/color/spacing choices, avoiding generic AI aesthetics), typography (text-wrap, tabular nums, font smoothing), surfaces (concentric radii, shadows over borders, optical alignment, image outlines), animations (interruptible transitions, enter/exit, icon micro-interactions), and performance (transition specificity, will-change).
Tags
README
Make Interfaces Feel Better
Design-engineering details that compound into polished interfaces. Model-invoked skill that fires when Claude works on UI polish, animations, shadows, borders, typography, enter/exit transitions, or anything whose success depends on getting small visual details right.
What it covers
The skill ships a top-level SKILL.md plus reference files that the skill pulls in on demand:
| Reference | When it applies |
|---|---|
design-direction.md |
Aesthetic identity (minimal/brutalist/editorial/…), typeface and color-palette selection, spacing scale, motion philosophy, avoiding generic AI-generated aesthetics. CCGM addition, not upstream. |
typography.md |
text-wrap: balance / pretty, font smoothing on macOS, tabular numbers for dynamic values |
surfaces.md |
Concentric border radius, optical vs geometric alignment, shadows instead of borders, image outlines, hit areas |
animations.md |
Interruptible animations (transitions vs keyframes), enter/exit transitions, icon micro-interactions, scale on press |
performance.md |
Transition specificity, will-change usage |
Trigger words (from the skill's frontmatter): UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
Usage
Model-invoked: Claude loads the skill automatically when the conversation is about visual polish or UI details. No slash command required.
Manual Installation
mkdir -p ~/.claude/skills/make-interfaces-feel-better
cp skills/make-interfaces-feel-better/*.md ~/.claude/skills/make-interfaces-feel-better/
Upstream
SKILL.md, typography.md, surfaces.md, animations.md, and performance.md are vendored from jakubkrehel/make-interfaces-feel-better (MIT). Content is based on Jakub Krehel's article Details that make interfaces feel better. Those skill files are copied verbatim; attribution belongs to the upstream author.
design-direction.md is a CCGM addition (folded in from the former frontend-design module) and is not part of the upstream repo. SKILL.md carries a one-line pointer to it.
To refresh the upstream files (this deliberately excludes design-direction.md, which has no upstream counterpart):
git clone --depth 1 https://github.com/jakubkrehel/make-interfaces-feel-better /tmp/mifb
# Copy only the upstream files; do NOT touch design-direction.md
for f in SKILL typography surfaces animations performance; do
cp "/tmp/mifb/skills/make-interfaces-feel-better/$f.md" \
modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/
done
# Re-add the design-direction pointer to SKILL.md if the refresh overwrote it.
Will install
| Path | Action | Target | Type |
|---|---|---|---|
skills/make-interfaces-feel-better/SKILL.md | → | skills/make-interfaces-feel-better/SKILL.md | skill |
skills/make-interfaces-feel-better/design-direction.md | → | skills/make-interfaces-feel-better/design-direction.md | doc |
skills/make-interfaces-feel-better/typography.md | → | skills/make-interfaces-feel-better/typography.md | doc |
skills/make-interfaces-feel-better/surfaces.md | → | skills/make-interfaces-feel-better/surfaces.md | doc |
skills/make-interfaces-feel-better/animations.md | → | skills/make-interfaces-feel-better/animations.md | doc |
skills/make-interfaces-feel-better/performance.md | → | skills/make-interfaces-feel-better/performance.md | doc |
Dependencies
No dependencies.
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://cd23a9be.ccgm-site.pages.dev/modules/make-interfaces-feel-better.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install make-interfaces-feel-better@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
skill (1)
skills/make-interfaces-feel-better/SKILL.md
---
name: make-interfaces-feel-better
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
---
# Details that make interfaces feel better
Great interfaces rarely come from a single thing. It's usually a collection of small details that compound into a great experience. Apply these principles when building or reviewing UI code.
## Quick Reference
| Category | When to Use |
| --- | --- |
| [Design Direction](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/design-direction.md) | Aesthetic identity, typeface/color/spacing choices, avoiding generic AI aesthetics (CCGM addition) |
| [Typography](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/typography.md) | Text wrapping, font smoothing, tabular numbers |
| [Surfaces](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/surfaces.md) | Border radius, optical alignment, shadows, image outlines, hit areas |
| [Animations](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/animations.md) | Interruptible animations, enter/exit transitions, icon animations, scale on press |
| [Performance](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/performance.md) | Transition specificity, `will-change` usage |
## Core Principles
### 1. Concentric Border Radius
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes interfaces feel off.
### 2. Optical Over Geometric Alignment
When geometric centering looks off, align optically. Buttons with icons, play triangles, and asymmetric icons all need manual adjustment.
### 3. Shadows Over Borders
Layer multiple transparent `box-shadow` values for natural depth. Shadows adapt to any background; solid borders don't.
### 4. Interruptible Animations
Use CSS transitions for interactive state changes — they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
### 5. Split and Stagger Enter Animations
Don't animate a single container. Break content into semantic chunks and stagger each with ~100ms delay.
### 6. Subtle Exit Animations
Use a small fixed `translateY` instead of full height. Exits should be softer than enters.
### 7. Contextual Icon Animations
Animate icons with `opacity`, `scale`, and `blur` instead of toggling visibility. Use exactly these values: scale from `0.25` to `1`, opacity from `0` to `1`, blur from `4px` to `0px`. If the project has `motion` or `framer-motion` in `package.json`, use `transition: { type: "spring", duration: 0.3, bounce: 0 }` — bounce must always be `0`. If no motion library is installed, keep both icons in the DOM (one absolute-positioned) and cross-fade with CSS transitions using `cubic-bezier(0.2, 0, 0, 1)` — this gives both enter and exit animations without any dependency.
### 8. Font Smoothing
Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text.
### 9. Tabular Numbers
Use `font-variant-numeric: tabular-nums` for any dynamically updating numbers to prevent layout shift.
### 10. Text Wrapping
Use `text-wrap: balance` on headings. Use `text-wrap: pretty` for body text to avoid orphans.
### 11. Image Outlines
Add a subtle `1px` outline with low opacity to images for consistent depth. The color must be pure black in light mode (`rgba(0, 0, 0, 0.1)`) and pure white in dark mode (`rgba(255, 255, 255, 0.1)`) — never a near-black like slate, zinc, or any tinted neutral. A tinted outline picks up the surface color underneath it and reads as dirt on the image edge.
### 12. Scale on Press
A subtle `scale(0.96)` on click gives buttons tactile feedback. Always use `0.96`. Never use a value smaller than `0.95` — anything below feels exaggerated. Add a `static` prop to disable it when motion would be distracting.
### 13. Skip Animation on Page Load
Use `initial={false}` on `AnimatePresence` to prevent enter animations on first render. Verify it doesn't break intentional entrance animations.
### 14. Never Use `transition: all`
Always specify exact properties: `transition-property: scale, opacity`. Tailwind's `transition-transform` covers `transform, translate, scale, rotate`.
### 15. Use `will-change` Sparingly
Only for `transform`, `opacity`, `filter` — properties the GPU can composite. Never use `will-change: all`. Only add when you notice first-frame stutter.
### 16. Minimum Hit Area
Interactive elements need at least 40×40px hit area. Extend with a pseudo-element if the visible element is smaller. Never let hit areas of two elements overlap.
## Common Mistakes
| Mistake | Fix |
| --- | --- |
| Same border radius on parent and child | Calculate `outerRadius = innerRadius + padding` |
| Icons look off-center | Adjust optically with padding or fix SVG directly |
| Hard borders between sections | Use layered `box-shadow` with transparency |
| Jarring enter/exit animations | Split, stagger, and keep exits subtle |
| Numbers cause layout shift | Apply `tabular-nums` |
| Heavy text on macOS | Apply `antialiased` to root |
| Animation plays on page load | Add `initial={false}` to `AnimatePresence` |
| `transition: all` on elements | Specify exact properties |
| First-frame animation stutter | Add `will-change: transform` (sparingly) |
| Tiny hit areas on small controls | Extend with pseudo-element to 40×40px |
## Review Output Format
Always present changes as a markdown table with **Before** and **After** columns. Include every change you made — not just a subset. Never list findings as separate "Before:" / "After:" lines outside of a table. Group changes by principle using a heading above each table, and keep each row focused on a single diff so the reader can scan the whole list quickly.
### Example
#### Concentric border radius
| Before | After |
| --- | --- |
| `rounded-xl` on card + `rounded-xl` on inner button (`p-2`) | `rounded-2xl` on card (`12 + 8`), `rounded-lg` on inner button |
| `border-radius: 16px` on both nested surfaces | Outer `24px`, inner `16px` with `8px` padding |
#### Tabular numbers
| Before | After |
| --- | --- |
| `<span>{count}</span>` on animated counter | `<span className="tabular-nums">{count}</span>` |
| Default numerals on timer | Added `font-variant-numeric: tabular-nums` to root |
#### Scale on press
| Before | After |
| --- | --- |
| `<button className="...">` | Added `active:scale-[0.96] transition-transform` |
| `scale(0.9)` on press | Raised to `scale(0.96)` — anything below `0.95` feels exaggerated |
Rows should cite the specific file and the specific property that changed when it isn't obvious from the snippet. If a principle was reviewed but nothing needed to change, omit that table entirely — empty tables add noise.
## Review Checklist
- [ ] Nested rounded elements use concentric border radius
- [ ] Icons are optically centered, not just geometrically
- [ ] Shadows used instead of borders where appropriate
- [ ] Enter animations are split and staggered
- [ ] Exit animations are subtle
- [ ] Dynamic numbers use tabular-nums
- [ ] Font smoothing is applied
- [ ] Headings use text-wrap: balance
- [ ] Images have subtle outlines
- [ ] Buttons use scale on press where appropriate
- [ ] AnimatePresence uses `initial={false}` for default-state elements
- [ ] No `transition: all` — only specific properties
- [ ] `will-change` only on transform/opacity/filter, never `all`
- [ ] Interactive elements have at least 40×40px hit area
## Reference Files
- [design-direction.md](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/design-direction.md) — Aesthetic identity, typeface/color/spacing choices, motion philosophy, avoiding generic AI aesthetics (CCGM addition, not upstream)
- [typography.md](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/typography.md) — Text wrapping, font smoothing, tabular numbers
- [surfaces.md](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/surfaces.md) — Border radius, optical alignment, shadows, image outlines
- [animations.md](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/animations.md) — Interruptible animations, enter/exit transitions, icon animations, scale on press
- [performance.md](https://github.com/lucasmccomb/ccgm/blob/f5122f94fbbe9475b72e2a36b04ae3e4ee98a0b7/modules/make-interfaces-feel-better/skills/make-interfaces-feel-better/performance.md) — Transition specificity, `will-change` usage
doc (5)
skills/make-interfaces-feel-better/design-direction.md
# Design Direction High-level design decisions that set a distinctive visual identity, before the implementation-level polish in the other reference files. The rest of this skill (typography rendering, surfaces, animations, performance) makes a chosen direction feel right; this file is about *choosing the direction* and avoiding generic, cookie-cutter UI that looks like every other AI-generated interface. > This reference is a CCGM addition, not part of the upstream `make-interfaces-feel-better` repo. When refreshing the other files from upstream, leave this one in place. ## 1. Intentional Aesthetics Choose a clear design direction and commit to it. Every project should have a distinct visual identity, not a default Bootstrap or Material look. - Pick a direction: minimal, brutalist, editorial, playful, corporate, retro — whatever fits the product - Execute it consistently across every component and page - When in doubt, reference the project's existing design language before inventing new patterns ## 2. Typography (Selection, not just Rendering) Typeface and type-scale choices are the single highest-impact design decision. (`typography.md` covers the *rendering* details — text-wrap, font smoothing, tabular numbers — once the typefaces are chosen.) - Choose fonts that match the product's personality (not just Inter or system fonts by default) - Establish a clear type scale with distinct hierarchy: display, heading, subheading, body, caption - Use font weight, size, and letter-spacing to create visual hierarchy, not just color - Limit to 2 font families maximum (one for headings, one for body) ## 3. Color Systems Build a cohesive color palette, not a random collection of hex values. - Define a primary, secondary, and accent color with intentional relationships - Use semantic color tokens (e.g., `text-primary`, `bg-surface`, `border-subtle`) not raw values - Ensure sufficient contrast ratios (WCAG AA minimum: 4.5:1 for text, 3:1 for UI elements) - Design for dark mode from the start if the project supports it ## 4. Spatial Composition Layout and spacing create rhythm and readability. - Use a consistent spacing scale (4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px) - Create visual hierarchy through whitespace, not just font size - Break out of predictable grid layouts when appropriate (asymmetry, overlap, varied column widths) - Generous padding and margins. When in doubt, add more space, not less. ## 5. Motion Philosophy Animation should be purposeful, not decorative. (`animations.md` covers the implementation — interruptible transitions, enter/exit, icon micro-interactions; this is the intent behind them.) - Focus on transitions that communicate state changes (loading, success, navigation) - Keep durations short (150-300ms for micro-interactions, 300-500ms for page transitions) - Use easing curves that feel natural (ease-out for entrances, ease-in for exits) - Respect `prefers-reduced-motion` for accessibility ## What to Avoid - **Generic AI aesthetics**: purple-to-blue gradients, overly rounded cards, generic hero sections with stock imagery - **Framework defaults**: unstyled Bootstrap, Material Design without customization, default Tailwind gray palette - **Inconsistency**: mixing design patterns from different systems or frameworks - **Decoration without purpose**: shadows, borders, gradients, and icons added for visual filler rather than to communicate information ## Implementation Checklist When building or modifying UI: 1. Check for existing design tokens, theme files, or style guides in the project 2. Follow established patterns before introducing new ones 3. Test at multiple viewport sizes (mobile, tablet, desktop) 4. Verify color contrast meets WCAG AA standards 5. Ensure interactive elements have visible focus states 6. Test with keyboard navigation
skills/make-interfaces-feel-better/typography.md
# Typography
Typography rendering details that make interfaces feel better.
## Text Wrapping
### text-wrap: balance
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
```css
/* Good — even line lengths on short text */
h1, h2, h3 {
text-wrap: balance;
}
```
```css
/* Bad — default wrapping leaves orphans */
h1 {
/* no text-wrap rule → "Read our
blog" instead of balanced lines */
}
```
```css
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
.article-body p {
text-wrap: balance;
}
```
**Tailwind:** `text-balance`
### text-wrap: pretty
Prevents orphaned words (a single word dangling on the last line) by adjusting line breaks throughout the paragraph. Unlike `balance`, it doesn't try to equalize line lengths — it just ensures the last line isn't embarrassingly short. Works on text of any length with no line-count limit.
This should be your **default for short-to-medium text** — paragraphs, descriptions, captions, list items, card text. For very long text (10+ lines), skip both `pretty` and `balance` — the browser's default wrapping is fine and you avoid unnecessary layout cost.
```css
/* Good — descriptions, captions, short paragraphs */
p, li, figcaption, blockquote {
text-wrap: pretty;
}
```
```tsx
// Tailwind
<p className="text-pretty">
A short paragraph that won't leave an orphan on the last line.
</p>
```
**Tailwind:** `text-pretty`
### When to Use Which
| Scenario | Use |
| --- | --- |
| Headings, titles where even distribution matters | `text-wrap: balance` |
| Short-to-medium text — paragraphs, descriptions, captions, UI text | `text-wrap: pretty` |
| Long text (10+ lines), code blocks, pre-formatted text | Neither — leave default |
## Font Smoothing (macOS)
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
```css
/* CSS */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
```
```tsx
// Tailwind — apply to root layout
<html className="antialiased">
```
### Good vs. Bad
```css
/* Good — applied once at the root */
html {
-webkit-font-smoothing: antialiased;
}
/* Bad — applied per-element, inconsistent */
.heading {
-webkit-font-smoothing: antialiased;
}
.body {
/* no smoothing → heavier than heading */
}
```
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
## Tabular Numbers
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
```css
/* CSS */
.counter {
font-variant-numeric: tabular-nums;
}
```
```tsx
// Tailwind
<span className="tabular-nums">{count}</span>
```
### When to Use
| Use tabular-nums | Don't use tabular-nums |
| --- | --- |
| Counters and timers | Static display numbers |
| Prices that update | Decorative large numbers |
| Table columns with numbers | Phone numbers, zip codes |
| Animated number transitions | Version numbers (v2.1.0) |
| Scoreboards, dashboards | |
### Caveat
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
```css
/* With Inter font:
Default: 1234 → proportional, "1" is narrow
Tabular: 1234 → all digits equal width, "1" centered */
```
skills/make-interfaces-feel-better/surfaces.md
# Surfaces
Border radius, optical alignment, shadows, and image outlines.
## Concentric Border Radius
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
```
outerRadius = innerRadius + padding
```
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
### Example
```css
/* Good — concentric radii */
.card {
border-radius: 20px; /* 12 + 8 */
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
/* Bad — same radius on both */
.card {
border-radius: 12px;
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
```
### Tailwind Example
```tsx
// Good — outer radius accounts for padding
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
...
</div>
</div>
// Bad — same radius on both
<div className="rounded-xl p-2">
<div className="rounded-xl"> {/* same radius, looks off */}
...
</div>
</div>
```
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
## Optical Alignment
When geometric centering looks off, align optically instead.
### Buttons with Text + Icon
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
`icon-side padding = text-side padding - 2px`.
```css
/* Good — less padding on icon side */
.button-with-icon {
padding-left: 16px;
padding-right: 14px; /* icon side = text side - 2px */
}
/* Bad — equal padding looks like icon is pushed too far right */
.button-with-icon {
padding: 0 16px;
}
```
```tsx
// Tailwind
<button className="pl-4 pr-3.5 flex items-center gap-2">
<span>Continue</span>
<ArrowRightIcon />
</button>
```
### Play Button Triangles
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
```css
/* Good — optically centered */
.play-button svg {
margin-left: 2px; /* shift right to account for triangle shape */
}
/* Bad — geometrically centered but looks off */
.play-button svg {
/* no adjustment */
}
```
### Asymmetric Icons (Stars, Arrows, Carets)
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
```tsx
// Best — fix in the SVG itself
// Adjust the viewBox or path to visually center the icon
// Fallback — adjust with margin
<span className="ml-px">
<StarIcon />
</span>
```
## Shadows Instead of Borders
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
### Shadow as Border (Light Mode)
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
```css
:root {
--shadow-border:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
--shadow-border-hover:
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
}
```
### Shadow as Border (Dark Mode)
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
```css
/* Dark mode — adapt to whatever setup the project uses
(prefers-color-scheme, class, data attribute, etc.) */
--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);
```
### Usage with Hover Transition
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
```css
.card {
box-shadow: var(--shadow-border);
transition-property: box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.card:hover {
box-shadow: var(--shadow-border-hover);
}
```
### When to Use Shadows vs. Borders
| Use shadows | Use borders |
| --- | --- |
| Cards, containers with depth | Dividers between list items |
| Buttons with bordered styles | Table cell boundaries |
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
| Elements on varied backgrounds | Hairline separators in dense UI |
| Hover/focus states for lift effect | |
## Image Outlines
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
### Color rules (non-negotiable)
- **Light mode**: pure black — `rgba(0, 0, 0, 0.1)`. Exact values: R=0, G=0, B=0.
- **Dark mode**: pure white — `rgba(255, 255, 255, 0.1)`. Exact values: R=255, G=255, B=255.
- Never use a near-black or near-white from the project palette (e.g. slate-900, zinc-900, `#0a0a0a`, `#111827`, `#f5f5f7`). Tinted outlines pick up the surrounding surface color and read as dirt on the image edge.
- Never match the outline to the project's accent or ink color. The outline is a neutral separator, not a themed element.
### Light Mode
```css
img {
outline: 1px solid rgba(0, 0, 0, 0.1);
outline-offset: -1px; /* inset so it doesn't add to layout */
}
```
### Dark Mode
```css
img {
outline: 1px solid rgba(255, 255, 255, 0.1);
outline-offset: -1px;
}
```
### Tailwind with Dark Mode
```tsx
<img
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
src={src}
alt={alt}
/>
```
Use `outline-black/10` and `outline-white/10` specifically — not `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*`, or any tinted scale.
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
## Minimum Hit Area
Interactive elements should have a minimum hit area of 44×44px (WCAG) or at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
### CSS Example
```css
/* Small checkbox with expanded hit area */
.checkbox {
position: relative;
width: 20px;
height: 20px;
}
.checkbox::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
}
```
### Tailwind Example
```tsx
<button className="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2">
<CheckIcon />
</button>
```
### Collision Rule
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
skills/make-interfaces-feel-better/animations.md
# Animations
Interruptible animations, enter/exit transitions, and contextual icon animations.
## Interruptible Animations
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
### CSS Transitions vs. Keyframes
| | CSS Transitions | CSS Keyframe Animations |
| --- | --- | --- |
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
| **Duration** | Adapts to remaining distance | Fixed regardless of state |
```css
/* Good — interruptible transition for a toggle */
.drawer {
transform: translateX(-100%);
transition: transform 200ms ease-out;
}
.drawer.open {
transform: translateX(0);
}
/* Clicking again mid-animation smoothly reverses — no jank */
```
```css
/* Bad — keyframe animation for interactive element */
.drawer.open {
animation: slideIn 200ms ease-out forwards;
}
/* Closing mid-animation snaps or restarts — feels broken */
```
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
## Enter Animations: Split and Stagger
Don't animate a single large container. Break content into semantic chunks and animate each individually.
### Step by Step
1. **Split** into logical groups (title, description, buttons)
2. **Stagger** with ~100ms delay between groups
3. **For titles**, consider splitting into individual words with ~80ms stagger
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
### Code Example
```tsx
// Motion (Framer Motion) — staggered enter
function PageHeader() {
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } },
}}
>
<motion.h1
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
Welcome
</motion.h1>
<motion.p
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
A description of the page.
</motion.p>
<motion.div
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
<Button>Get started</Button>
</motion.div>
</motion.div>
);
}
```
### CSS-Only Stagger
```css
.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);
}
}
```
## Exit Animations
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
### Subtle Exit (Recommended)
```tsx
// Small fixed translateY — indicates direction without drama
<motion.div
exit={{
opacity: 0,
y: -12,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeIn" },
}}
>
{content}
</motion.div>
```
### Full Exit (When Context Matters)
```tsx
// Slide fully out — use when spatial context is important
// (e.g., a card returning to a list, a drawer closing)
<motion.div
exit={{
opacity: 0,
x: "-100%",
transition: { duration: 0.2, ease: "easeIn" },
}}
>
{content}
</motion.div>
```
### Good vs. Bad
```css
/* Good — subtle exit */
.item-exit {
opacity: 0;
transform: translateY(-12px);
transition: opacity 150ms ease-in, transform 150ms ease-in;
}
/* Bad — dramatic exit that steals focus */
.item-exit {
opacity: 0;
transform: translateY(-100%) scale(0.5);
transition: all 400ms ease-in;
}
/* Bad — no exit animation at all (element just vanishes) */
.item-exit {
display: none;
}
```
**Key points:**
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
- Keep some directional movement to indicate where the element went
- Exit duration should be shorter than enter duration (150ms vs 300ms)
- Don't remove exit animations entirely — subtle motion preserves context
## Contextual Icon Animations
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
### Motion Example
```tsx
import { AnimatePresence, motion } from "motion/react";
function IconButton({ isActive, icon: Icon }) {
return (
<button>
<AnimatePresence mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<Icon />
</motion.span>
</AnimatePresence>
</button>
);
}
```
### CSS Transition Approach (No Motion)
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
```tsx
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
return (
<button>
<div className="relative">
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"transition-[opacity,filter,scale] duration-300",
"cubic-bezier(0.2, 0, 0, 1)",
isActive
? "scale-100 opacity-100 blur-0"
: "scale-[0.25] opacity-0 blur-[4px]"
)}
>
<ActiveIcon />
</div>
<div
className={cn(
"transition-[opacity,filter,scale] duration-300",
"cubic-bezier(0.2, 0, 0, 1)",
isActive
? "scale-[0.25] opacity-0 blur-[4px]"
: "scale-100 opacity-100 blur-0"
)}
>
<InactiveIcon />
</div>
</div>
</button>
);
}
```
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
### Choosing Between Motion and CSS
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
| --- | --- | --- |
| **Enter animation** | Yes | Yes |
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |
**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
### When to Animate Icons
| Animate | Don't animate |
| --- | --- |
| Icons that appear on hover (action buttons) | Static navigation icons |
| State change icons (play → pause, like → liked) | Decorative icons |
| Icons in contextual toolbars | Icons that are always visible |
| Loading/success state indicators | Icon labels (text next to icon) |
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)
- `opacity`: `0` → `1`
- `filter`: `"blur(4px)"` → `"blur(0px)"`
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value
## Scale on Press
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
### CSS Example
```css
.button {
transition-property: scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.button:active {
scale: 0.96;
}
```
### Tailwind Example
```tsx
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
Click me
</button>
```
### Motion Example
```tsx
<motion.button whileTap={{ scale: 0.96 }}>
Click me
</motion.button>
```
### Static Prop Pattern
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
```tsx
const tapScale = "active:not-disabled:scale-[0.96]";
function Button({ static: isStatic, className, children, ...props }) {
return (
<button
className={cn(
"transition-transform duration-150 ease-out",
!isStatic && tapScale,
className,
)}
{...props}
>
{children}
</button>
);
}
// Usage
<Button>Click me</Button> {/* scales on press */}
<Button static>Submit</Button> {/* no scale */}
```
## Skip Animation on Page Load
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
### When It Works
```tsx
// Good — icon doesn't animate in on mount, only on state change
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
>
<Icon />
</motion.span>
</AnimatePresence>
```
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
### When It Breaks
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
```tsx
// Bad — initial={false} would skip the staggered page enter entirely
<AnimatePresence initial={false}>
<motion.div initial="hidden" animate="visible" variants={...}>
...
</motion.div>
</AnimatePresence>
```
Verify the component still looks right on a full page refresh before applying this.
skills/make-interfaces-feel-better/performance.md
# Performance
Transition specificity and GPU compositing hints.
## Transition Only What Changes
Never use `transition: all` or Tailwind's `transition` shorthand (which maps to `transition-property: all`). Always specify the exact properties that change.
### Why
- `transition: all` forces the browser to watch every property for changes
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
- Prevents browser optimizations
### CSS Example
```css
/* Good — only transition what changes */
.button {
transition-property: scale, background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
/* Bad — transition everything */
.button {
transition: all 150ms ease-out;
}
```
### Tailwind
```tsx
// Good — explicit properties
<button className="transition-[scale,background-color] duration-150 ease-out">
// Bad — transition all
<button className="transition duration-150 ease-out">
```
### Tailwind `transition-transform` Note
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
## Use `will-change` Sparingly
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
### Rules
```css
/* Good — specific property that benefits from GPU compositing */
.animated-card {
will-change: transform;
}
/* Good — multiple compositor-friendly properties */
.animated-card {
will-change: transform, opacity;
}
/* Bad — never use will-change: all */
.animated-card {
will-change: all;
}
/* Bad — properties that can't be GPU-composited anyway */
.animated-card {
will-change: background-color, padding;
}
```
### Useful Properties
| Property | GPU-compositable | Worth using `will-change` |
| --- | --- | --- |
| `transform` | Yes | Yes |
| `opacity` | Yes | Yes |
| `filter` (blur, brightness) | Yes | Yes |
| `clip-path` | Yes | Yes |
| `top`, `left`, `width`, `height` | No | No |
| `background`, `border`, `color` | No | No |
### When to Skip
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.