Foundations
Motion
Two durations and one curve. Halbton's state changes are small enough that a longer transition reads as lag rather than polish — and the whole system resolves to instant under reduced-motion.
The tokens
Two durations and one easing. Halbton's state changes are small enough that a longer curve reads as lag rather than polish.
| Token | Value | Use |
|---|---|---|
| --ht-dur-fast | 120ms | Colour changes on hover and active. Fast enough to feel like a response rather than an animation. |
| --ht-dur-base | 180ms | Something entering or leaving the page — a dialog, a drawer, a toast. |
| --ht-ease | cubic-bezier(0.2, 0, 0, 1) | One curve for everything. It starts immediately and decelerates, which is what makes a change feel caused by the click. |
Try them
Hover each square. The difference between 120ms and 180ms is small and that is the point — the first feels like the surface responding, the second like something moving. Above roughly 250ms a hover state starts to feel broken when you move quickly across a dense page.
Focusable with the keyboard as well as hoverable, because a demonstration only reachable with a pointer is not a demonstration for everyone.
What is never animated
- The theme flip. Transitioning colour on data-theme produces a visible sweep on every toggle and a flash of half-applied colour on first paint. It is instant, and the tokens are declared with transition-property: none to keep it that way.
- Anything on page load. No entrance animations, no staggered reveals. A tool should be usable the moment it renders.
- Position. Nothing lifts, scales or translates on hover — see the half-step argument on Elevation.
Reduced motion
Every animation in the system resolves to its end state, not to a shorter version of itself. A 60ms spin is not a courtesy; it is the same problem, faster.
@media (prefers-reduced-motion: reduce) {
.ht-btn {
transition: none;
}
/* Resolved, not shortened. The ring stays visible so the
button still reads as busy — the meaning survives, the
movement does not. */
.ht-btn__spinner {
animation: none;
border-right-color: currentColor;
}
} The pattern generalises: ask what the animation was communicating, then deliver that without movement. A spinner communicates "busy" — a static ring plus aria-busy does too. A dialog transition communicates "this arrived over the page" — a border and a scrim do too.
When to add motion at all
Only to explain a change the user caused. If something appears, moves or disappears and the user did not act, it does not need easing — it needs removing.