The Boring Components
A good animation plays once per visit. A button gets pressed every time anyone does anything. Buttons, fields, switches, tabs, toasts: they ship in every feature, nobody puts them in a portfolio, and they’re where most of the bugs are.
I learned that the uncomfortable way. When I audited the components on this site, the flashy ones had problems, but the boring ones had more, and several were invisible in a screenshot: borders that didn’t render, labels that weren’t connected to their inputs, focus rings that had been quietly erased. This post covers what I changed.
Buttons
Three variants (primary, secondary, tertiary), three tones (brand, neutral, destructive), three sizes on a 4px grid:
- small: 32px tall, 12px side padding, 14px text
- medium: 40px, 16px, 16px text
- large: 48px, 24px, 18px text
Nine variant and tone pairs in three sizes is what the system allows, not what anything needs. Three variants plus a destructive tone cover everything on this site. There was a fourth tone, inverse, that nothing used, so I deleted it, along with an icon prop, a full-width option and an htmlType prop that stood in for the native type. The button now takes type like any button and defaults it to "button", because the HTML default is submit.
Pressing scales the button to 97% over 150ms with plain CSS :active, and a still prop turns that off for dense rows. :active covers mouse, touch and holding Space. Enter doesn’t trigger it: Enter activates the button on key down, so a keyboard press has no squash. I’ve left that alone. The focus ring is already on the button and the result is immediate, and faking a pointer press for a key felt like the wrong fix. Hover only changes the colour, and only on devices with a real pointer, since a touch screen fires hover on tap and leaves it stuck. It used to scale up too, which made every row of buttons twitch as the pointer crossed it.
The bug I found here was in the design tokens. Some of them hold whole box-shadow values, and a few components used them as colours:
/* --stroke-brand-weak is "0 0 0 1px rgb(…)", not a colour */
border: 1px solid rgb(var(--stroke-brand-weak));A declaration with var() in it can’t be checked when the stylesheet is parsed, so the browser accepts this one. It fails later, when the variable is substituted and the result isn’t a colour. At that point the property behaves as if it were unset, which for border means no border at all. It also means a fallback can’t save you: a border: 1px solid gray earlier in the same rule has already lost the cascade to this one. Secondary buttons, the switch track and the active step all lost their outlines this way, and nothing warned me.
The real cause was the names. --stroke-weak was a shadow and --stroke-color-weak was a colour, and nothing about either name said which. So the fix wasn’t only using the token as a box-shadow. I renamed every shadow token to --ring-*, and the hover and press overlays to --overlay-*, so rgb(var(--ring-brand-weak)) looks wrong the moment you type it.
Focus you can see
The site drew its focus ring as a box-shadow. Any component that set its own shadow for hover or elevation had the same specificity, loaded later, and silently replaced the ring. The carousel dots, among others, had no visible focus at all. The ring is now an outline, which doesn’t compete with shadows:
:focus-visible {
outline: 2px solid rgb(var(--text-strong));
outline-offset: 2px;
}It’s the text colour rather than the brand colour, so a focused button never looks like a selected one next to brand-filled controls. Outlines have followed border-radius since Chrome 94, Firefox 88 and Safari 16.4, the last of those in 2023, so the old reason to fake the ring with a shadow is gone.
The better reason for an outline turned out to be forced colours, the mode Windows calls High Contrast. In it, the browser removes every box-shadow and repaints outlines and borders in the user’s system colours. A shadow ring would have vanished for exactly the people who need it most. The same went for every hairline on this site, since they were all shadows: in forced colours the buttons were bare text, the field had no box and the switch disappeared. Each of those controls now also has a transparent 1px border, sized so nothing moves, which stays invisible until that mode paints it. Selected states use system colours, which forced colours leave alone:
@media (forced-colors: active) {
.trackChecked {
background: Highlight;
}
}The selected segment and theme option fill with Highlight the same way, and the active tab’s underline and the current step’s ring are drawn in it.
Fields
A visible label above the field, tied to it with htmlFor and an id from useId(). Placeholder text is an example, never the label. Errors show up below the field in words, are linked with aria-describedby, and set aria-invalid. The red ring is a box-shadow so it doesn’t change the field’s size, and the focus outline still shows on top of it.
Nothing is flagged until you leave the field with a bad value. After that, the message updates as you type, so it disappears the moment the address is fixed. My first version got this backwards: leaving the field once, even with a valid address, turned on checking for every keystroke. It also shook the field whenever it turned invalid, which, once checking was live, meant it could shake halfway through a word. I took the shake out instead of fixing its timing. The ring and the message already say what’s wrong without moving anything.
On touch screens, fields use at least 16px text: below that, iOS zooms the whole page when you tap into one.
Choosing between options
A segmented control, tabs and a switch look like three versions of one thing. They aren’t. Each one tells the keyboard and a screen reader something different about how it’s operated and when the change happens:
| Control | Role | Keys | Takes effect |
|---|---|---|---|
| Segmented control | radiogroup | One Tab stop; arrows move the selection | Immediately |
| Tabs | tablist | Arrows, Home and End pick a tab; Tab moves into its panel | Immediately, by showing that tab’s panel |
| Switch | switch | Space or Enter flips it | The moment it flips |
| Checkbox | checkbox | Space ticks it | When the form is saved |
The last two rows are the ones I see mixed up most. A switch inside a form with a Save button claims the change already happened when it hasn’t. If a setting only applies on Save, it’s a checkbox.
My own tabs demo broke the second row: it had no panel, which made it a segmented control with an underline. Each tab now has an id, and the panel under it is a tabpanel labelled by the selected tab, so tabbing into it announces which section you’re in.
The segmented thumb and the tab underline are both a shared-layout element rendered inside the selected option, so they slide between options of different widths. Each instance gets its own layoutId from useId(). Otherwise two controls on one page share an indicator, and it flies across the page between them.
Moving with the arrow keys switches instantly. Someone arrowing through options already sees the focus move, and making them wait for an animation on every key press is friction. The switch’s thumb uses a spring with a little overshoot (ζ ≈ 0.67), and clicking its label toggles it, like a native checkbox.
Progress
The step indicator is an ordered list. The current step has aria-current="step", completed steps say so in hidden text, and the “Step 2 of 4” line and the step title sit in a polite live region, so moving between steps is announced. The region is atomic, and the line is one string. My first version rendered “Step ”, the number, “ of ” and the total as separate text nodes, and without aria-atomic a screen reader may read only the node that changed: “2”. The bar above the steps scales horizontally to the share of steps reached rather than animating its width.
Step 1 of 4
Account
- Account
- Profile
- Preferences
- Done
Toasts
A toast is for background confirmation: something happened and nobody has to do anything about it. If you have to act, the message belongs next to whatever caused it, or the toast stays until you dismiss it and carries the action itself. A connection error has nowhere else to go, so here it waits for you and has a Retry button. Undo is the other case: the Delete button in the first demo shows a toast that can’t leave on its own, because the action is the whole point of it.
Toasts are easy to get almost right. What changed here:
- One live region is always on the page, and toasts are added inside it. A region created at the same moment as its message often isn’t announced at all.
- Success and info stay for about as long as they take to read: 300ms a word, never less than 4 seconds. Errors, and any toast with a button in it, stay until you dismiss them or use the button. A loading toast stays until it resolves. Hovering or focusing the stack pauses every timer, and so does switching browser tabs, so nothing disappears while you’re not looking.
- The same message twice in a row doesn’t stack a copy. The toast already on screen shows a count (“×3”) and restarts its timer.
- Every toast except a loading one has a dismiss button. After dismissing with the keyboard, focus moves to the next toast instead of falling back to the top of the page.
Collapsed, each older toast sits 8px higher and 5% smaller than the one in front, three deep. Hover or focus spreads them out using each toast’s measured height, so a toast whose text wraps still gets the right amount of space. Entry and exit are CSS transitions, with entry defined by @starting-style.
Switching themes
The theme switch is a radio group: Light, Dark and System, one Tab stop, arrow keys to move. It started as two buttons with aria-pressed, where pressing the one already pressed did nothing. That’s a radio button announced as something else. And System was missing, even though it’s the site’s default. The highlight marks what you chose, not what it resolved to, so System stays highlighted when your operating system switches to dark.
When the choice changes the page’s colours, the switch uses the View Transitions API: the browser takes a snapshot of the page, the theme changes, and the new theme is revealed as a circle growing from the option over 500ms. Three details made it reliable. The theme class is applied inside flushSync, so the “after” snapshot really is after. Keyboard changes have no pointer position, so the circle starts from the centre of the option instead of the corner of the screen. And next-themes switches CSS transitions off while it changes the theme class, so the highlight’s slide is a Web Animation, which that doesn’t touch.
Icons that answer back
Twelve small icons. I’ll be straight about these: six of them (like, play, mute, show password, copy, send) share one animation, a quick crossfade where the new icon scales up from 25% as the old one blurs out. That’s fine. They’re doing the same job, swapping one state for another, and one consistent motion is better than six clever ones. Copy and Send add behaviour on top of that swap: Copy actually writes to your clipboard and says “Copied” to screen readers, and Send shows a loading state before its check. Like is a thumbs-up. It used to be a second heart, right next to Favorite with a different animation, so the same shape meant two things.
The other six have something specific to say. The heart pops with a bouncy spring when you fill it and doesn’t bounce when you unfill it. The star turns 72° as it fills, which a five-pointed star can do without looking any different at the end. The chevron turns to show whether the details are open. The download arrow drops into a tray and a check draws in. And two aren’t buttons at all: a spinner and an “online” dot, which are status indicators with their own names.
One rule from this set I’d apply everywhere: a state change you can only see isn’t feedback for everyone. Every one of these either changes its accessible state (aria-pressed, aria-expanded) or announces the result.
Why this is the real work
None of these fixes would show up in a screenshot, which is exactly why they were broken. I’m not going to pretend there are none left. What I have now is a habit: before a component is done, I use it with only a keyboard, with a screen reader on, at phone width, in dark mode and in forced colours. The boring components are where that habit catches the most.