How buttons should change when a cursor enters them — and the system-level rules that keep those changes coherent across a full button hierarchy.
Before anything animates, the cursor itself is the first signal. It changes the moment the pointer enters the element, before any transition begins — instant, binary, unmissable.
The debate over cursor: pointer on buttons is surprisingly unresolved. The HTML spec associates the pointer (hand) cursor with links, not buttons — the browser default for <button> elements is cursor: default (the arrow). The argument for keeping the arrow: it correctly distinguishes buttons (which perform actions) from links (which navigate). The argument for the pointer: users have been trained by two decades of web use to associate the hand cursor with "this is clickable," and withholding it on buttons creates a moment of uncertainty.
In practice, the vast majority of modern design systems — shadcn/ui, Material, Carbon, Apple HIG web components — use cursor: pointer on all interactive elements including buttons. The semantic purity argument has lost to user expectation. The recommendation: use pointer. It removes one moment of doubt, and the semantic distinction between links and buttons is better communicated through visual design than through cursor shape.
Every hover effect should be animated, not instantaneous. The transition between default and hover states is what makes an interface feel crafted rather than mechanical. But the parameters matter.
| Parameter | Value | Rationale |
|---|---|---|
| Enter duration | 120–200ms | Fast enough to feel immediate, slow enough to register as a transition. The sweet spot for color/opacity shifts is 150ms. |
| Leave duration | 150–250ms | Slightly longer than enter. The asymmetry feels natural — response is snappy, return to rest is relaxed. |
| Enter easing | ease-out | Decelerating curve. The change happens quickly at first and settles gently — feels responsive. |
| Active (pressed) | 40–80ms | Must feel instant. The pressed state should snap into place. A sluggish press transition is one of the most common sources of perceived UI lag. |
The right hover effect depends on the button's resting fill state. A solid button can't get darker if it's already black. An outlined button that fills to match the solid button's color collapses the visual hierarchy. Each fill type has its own set of viable strategies.
Opacity reduction is the simplest and most universal approach. It works on any color, including pure black. The button becomes slightly translucent, letting the background show through. Keep it subtle — 82–90% opacity is the safe range. Below ~75%, text contrast can fail.
Lightening the background is the most common approach when the primary isn't pure black. Shadcn/ui uses bg-primary/90. This works well for hues or dark grays, but not for #000 — "lightening" pure black to a visible gray feels washed out rather than interactive.
Lift (translateY + shadow increase) communicates hover through spatial change rather than tonal change, which means it works universally regardless of fill color. The subtle upward shift (1–2px) creates a physical metaphor: "this element is rising to meet your cursor." This is the most premium-feeling option — used by Apple, Stripe, and Vercel for primary actions.
Scale is energetic and attention-grabbing — appropriate for marketing CTAs but potentially distracting in dense application UIs where many buttons cluster together.
Fill in (outlined → solid) is the most dramatic hover effect. But it has a systemic problem: if the outlined button fills to the same color as the primary button next to it, the two become visually identical on hover. Use fill-in only when the outlined button won't appear adjacent to a solid, or when the fill color is a lighter shade rather than the same dark.
Tint (adding a 5–8% background) is the safest default. The button stays clearly "secondary" even on hover. This is the approach used by most modern systems.
Border intensification — darkening a faint border on hover — is a good complement to tint. If the resting border is at 20% opacity, increasing it to 40–50% on hover makes the button feel more defined.
Ghost → tint is the most universally safe hover effect. The ghost button goes from invisible-background to barely-visible-background. It can't collide with any other button's hover state. This is why every modern design system uses it.
The :active state is triggered while the user is physically pressing the button. It should feel like pressing a physical surface: immediate, slight, and brief.
The best active states reverse the hover direction. If hover lifts up (translateY(-1px)), active pushes back down (translateY(0)). If hover lightens, active darkens. This creates a satisfying push-release cycle that mirrors physical button mechanics. The active transition should be 40–80ms — very fast, to feel "snappy."
For tinted and ghost buttons, the active state typically deepens the tint further: if hover is 8% opacity, active might be 14–18%. The key constraint: the active state of a secondary button should never approach the visual weight of the primary button's resting state.
Icons within buttons can stay static (most common), animate (rotate, shift, bounce), or appear/disappear on hover. Each has appropriate uses.
Revealing an icon on hover — typically a trailing arrow — is an elegant pattern for "continue" or "learn more" buttons. It communicates directionality and forward momentum. This pattern is used by Stripe, Linear, and Vercel on marketing pages.
Animating an existing icon — nudging a trailing arrow rightward by 3–4px on hover — is a lighter version. It works well for navigation buttons and link-style buttons.
Keeping icons static is the right choice for application buttons in dense UIs. The button's tonal hover effect is sufficient. Icon animation in toolbars and forms creates visual chaos.
A hover strategy only works as a system when every button's hover state preserves the hierarchy that the resting states established. If hover collapses the visual distinction between primary and secondary, the system is broken — even if each button's hover effect looks good in isolation.
Rule 1: A secondary button's hover state must never match the primary button's resting state. This is the most common violation. If your primary is solid black and your outlined secondary fills to solid black on hover, the user's brain can't distinguish between "I'm hovering on the secondary" and "that's the primary." The fix: make the outlined hover a tint or a lighter fill, not a full solid.
Rule 2: Hover should move in one direction on the contrast scale. Solid buttons get lighter (or lift). Outlined buttons get slightly filled. Tinted buttons get deeper. Ghost buttons get tinted. Every tier moves toward its next-higher neighbor, but never reaches it.
Rule 3: The same hover technique should apply to the same fill type everywhere. If your outlined buttons tint on hover in the header, they should tint on hover in the sidebar, in modals, and in forms. Inconsistency in hover behavior is more disorienting than inconsistency in resting state, because hover is a real-time response to user action.
Rule 4: Hover contrast must still pass accessibility. If your solid button's text contrast is 13:1 at rest, reducing opacity to 82% on hover drops it to ~10:1 — still passing. But if your outlined button's border is already marginal at 2.5:1, hover should improve it, not leave it unchanged.
Below are three internally consistent hover systems applied to a full four-tier button hierarchy. Each uses a different primary-button hover strategy and derives all other tiers from it.
Primary lightens via white overlay. All other tiers deepen their fill. No layout shift. Purely tonal. The safest, most conservative option — used by shadcn/ui, Radix, Linear, and most SaaS products.
Primary lifts and gains shadow — spatial movement reserved for the most important action. Secondary border intensifies with a tint. Common on marketing pages and in design-forward products like Stripe and Vercel.
Primary fades slightly. Secondary fills in fully — text inverts. The boldest system. It works when the secondary is rarely adjacent to the primary, or when the fill-in uses a lighter shade. Use with caution in dense UIs.
The most reusable hover technique across a button system is the semi-transparent overlay: a white or black layer at low opacity, applied via a pseudo-element, that works regardless of fill color. White overlay to lighten dark fills; black overlay to darken light fills.
This technique is powerful because the same overlay code works on any base color. Change the brand color and every hover state updates automatically — no manual retuning. The simpler background: primary/90 approach achieves the same for solid buttons without the pseudo-element, but doesn't generalize as cleanly to outlined and ghost variants.
Everything above is desktop-only. On touch devices, there is no hover. Key considerations for desktop designers to keep in mind now, with full mobile treatment deferred to a future part:
Never gate information behind hover. If an icon only appears on hover, mobile users will never see it. Use hover as enhancement, not the sole mechanism for communicating something.
The :active state does double duty on mobile. Since there's no hover, active is the only visual feedback a touch user gets. It needs to be slightly more pronounced than on desktop.
Use @media (hover: hover) to scope hover styles. This media query applies hover effects only on devices with true pointer hover, preventing the "sticky hover" problem on touch devices.
Part III of the Button Taxonomy. Part I covers structure. Part II covers contrast and construction.
The best hover state is the one that preserves the hierarchy.