Contents
Contents
The difference between an interface that feels off and one that feels polished is rarely one big thing. It's an accumulation of small decisions — each invisible on its own, collectively transforming the experience. Here are twelve.
By default, browsers break lines wherever they fit. This produces headings where the last line holds one or two lonely words — an orphan. text-wrap: balance asks the browser to distribute words as evenly as possible across all lines. For body copy, text-wrap: pretty avoids orphans without the aggressive rebalancing.
It's a one-liner that eliminates a class of awkward heading breaks entirely.
h1, h2, h3 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}Small details make interfaces feel significantly more polished
The quick brown fox jumps over the lazy dog. Some sentences end on a single lonely word.
Default — lines break wherever they fit, leaving orphaned words.
On macOS and iOS, text renders with subpixel antialiasing by default — a technique that uses RGB subpixels to simulate higher resolution. It works, but it adds weight to strokes, making text look heavier than designed.
-webkit-font-smoothing: antialiased switches to grayscale antialiasing. Text looks thinner, crisper, and closer to what you see in design tools. Apply it globally and forget about it.
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}The quick brown fox jumps over the lazy dog.
ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789
Default — browser decides, typically subpixel antialiasing on macOS.
When you nest a rounded element inside another — a button inside a card, an input inside a container — matching border-radius values look wrong. The inner and outer curves don't share the same center. The rule is simple: inner radius = outer radius − padding.
.card {
border-radius: 24px;
padding: 12px;
}
.card-inner {
border-radius: 12px; /* 24 - 12 */
}Adjust the sliders to see the relationship live. When the formula breaks, the corners lose their relationship with each other.
inner = outer − padding = 24 − 12 = 12
Images with light content need an edge to separate them from a light page background. The instinct is to reach for border: 1px solid. The problem: a border adds to the box model. The element becomes 2px wider and taller, which shifts surrounding layout.
Use box-shadow: inset 0 0 0 1px instead. It sits inside the element, occupying no space, affecting nothing around it.
img {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.10);
}No outline — image blends into a light page background.
In proportional fonts, each digit has its own width — a 1 is narrower than an 8. Fine for body text. Catastrophic for numbers that change: counters, prices, timestamps. As digits update, the surrounding characters shift.
font-variant-numeric: tabular-nums gives all digits equal width. Columns stay locked in place. Apply it anywhere numbers change or need to align vertically.
.price,
.counter,
time {
font-variant-numeric: tabular-nums;
}Hit animate to see the difference under live updates.
proportional — digits different widths, columns shift as values change.
When multiple elements appear at once — a grid loading, a list opening — animating them all simultaneously looks mechanical. A staggered animation-delay creates a cascade that reads as intentional and guides the eye naturally.
Keep delays short. 40–60ms per item is plenty. If the total sequence takes longer than 400ms, it starts to feel slow.
.item {
animation: slide-up 0.4s ease forwards;
opacity: 0;
}
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 55ms; }
.item:nth-child(3) { animation-delay: 110ms; }40–60ms delay per item. Keep the total sequence under 400ms or it starts to feel slow.
Trigger a CSS transition, then click again before it finishes. The transition picks up from its current position — smooth, continuous, natural. A @keyframes animation restarts from the beginning — a jarring jump.
For any reversible state — hover, toggle, open/close — always use CSS transitions. Save @keyframes for animations that play once and don't need to reverse.
/* ✓ transition — picks up from current position */
.element {
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ✗ keyframe — restarts from 0% on interrupt */
.element {
animation: move 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}transition
keyframe
Transition picks up from current position. Keyframe restarts from the beginning.
Most UI implementations only animate enter. The element appears smoothly, then disappears instantly on dismiss — a jarring cut. Exit deserves its own animation.
Asymmetric easing makes both feel right. Enter uses ease-out: quick start, gradual settle — mimicking something arriving. Exit uses ease-in: gradual start, quick end — something leaving.
@keyframes panel-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes panel-exit {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(6px); }
}
.panel[data-entering] {
animation: panel-enter 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.panel[data-exiting] {
animation: panel-exit 0.3s cubic-bezier(0.4, 0, 1, 1);
}Enter: ease-out (quick start, gradual settle). Exit: ease-in (gradual start, quick end).
A single box-shadow casts one flat shadow. Real objects cast two simultaneously: a tight contact shadow beneath (where light is most blocked) and a diffuse ambient shadow extending further. Pairing the two adds depth a single value can't replicate.
Adding a 1px outline at zero blur also defines the element's edge against light backgrounds — a replacement for the border trick mentioned earlier.
.card {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.10), /* contact */
0 4px 16px rgba(0, 0, 0, 0.08), /* ambient */
0 0 0 1px rgba(0, 0, 0, 0.04); /* edge */
}No shadow — element is flat against the surface.
Two patterns worth keeping in your toolkit. Single-line truncation is straightforward. Multi-line truncation with -webkit-line-clamp works universally despite the vendor prefix.
/* single line */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* multi-line */
.clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}In Tailwind: truncate for single-line, line-clamp-{n} for multi-line. Always pair truncated text with a title attribute — screen readers and keyboard users need access to the full content.
Mathematical centering — equal padding on all sides — often looks slightly off. Icons with visual weight concentrated toward one edge (a chevron pointing down, an arrow pointing right) read as misaligned even when the math is perfect. The eye perceives visual mass, not pixel coordinates.
Optical alignment compensates: a 1–2px nudge toward the heavier side makes an icon look centered even if it isn't mathematically. The amount is always small, always felt rather than measured.
/* chevron is visually heavy at the bottom */
.icon-chevron {
transform: translateY(-1px);
}Mathematically centered — equal padding, but the chevron reads as sitting low.
The most common button mistake: align-items: flex-start (or omitting alignment entirely) when combining an icon with text. The icon snaps to the top of the text block and anything taller than a single line looks broken.
Use align-items: center. Keep icon sizes close to the text cap-height — a 16px icon pairs well with 13–14px text. Going larger starts to dominate.
<button className="inline-flex items-center gap-2 px-4 py-2">
<PlusIcon className="size-4" />
New project
</button>items-start — icon aligns to the top of the text block, appears raised.
None of these details are dramatic on their own. Most are invisible when done right — which is exactly the point. Polish isn't a feature. It's the absence of friction.