Skip to content

fix(Button): hover/cursor affordances + feat(ButtonLink) for navigation#307

Open
runleveldev wants to merge 3 commits into
mainfrom
button-affordance-and-buttonlink
Open

fix(Button): hover/cursor affordances + feat(ButtonLink) for navigation#307
runleveldev wants to merge 3 commits into
mainfrom
button-affordance-and-buttonlink

Conversation

@runleveldev

Copy link
Copy Markdown

Summary

Three related fixes for button affordances, motivated by a consumer report in opensource-server/create-a-container where ghost buttons appeared to have no hover state at all (users only saw feedback when clicking) and navigation "buttons" showed the wrong cursor.

1. fix(Button): pointer cursor + perceptible ghost hover

  • cursor-pointer in the base styles. Tailwind v4's preflight resets buttons to cursor: default, so Button silently lost its clickable affordance. QuickAction already opts back in — this brings Button to parity with its own siblings.
  • Ghost hover moves one token step: hover:bg-neutral-100 → 200, active: 200 → 300 (dark: hover: 800 → 700, active: 700 → 600). Measured on a white page, neutral-100 (#f5f5f5) is a ~4% luminance delta — effectively invisible on most displays, which is why hover read as "nothing happens" and the (ungated, stronger) active state was the only feedback users noticed.

2. feat(ButtonLink): navigation links styled as buttons

Consumers wrap <Button> in a router <Link> to get button-looking navigation. That nests interactive elements (invalid HTML), creates a double tab stop, and the button's cursor hides the link pointer. ButtonLink reuses buttonVariants on a real link element:

<ButtonLink href="/docs" variant="outline">Docs</ButtonLink>

// SPA routers: pass the link component to keep client-side navigation
<ButtonLink as={Link} to="/users/new" variant="primary" leftIcon={<Plus />}>
  New user
</ButtonLink>

Real links keep middle-click/Ctrl+click to a new tab, "copy link address", and correct assistive-technology semantics. It's deliberately a separate component rather than as/asChild on Button: links have no disabled/loading semantics, so each keeps an honest API. Ships with autodocs story, unit tests, barrel export, and a tsup entry.

3. fix(styles): apply hover styles unconditionally

Tailwind v4 wraps the built-in hover: variant in @media (hover: hover). Browsers in VMs/remote desktops and some touchscreen laptops misreport pointer capability, which silently disables every hover style in the compiled CSS even though :hover still matches actual mouse movement. Added @custom-variant hover (&:hover); next to the existing dark custom variant, per Tailwind's documented escape hatch. Tradeoff: pre-v4 behavior returns on touch devices (hover styles may linger after a tap).

Testing

  • pnpm typecheck && pnpm lint && pnpm format && pnpm test — all green (255 tests, incl. 6 new ButtonLink tests)
  • Visual baselines (button-primary.png, button-all-variants.png) capture non-hovered states, so the ghost hover change should not disturb them; cursor never appears in screenshots. Happy to run pnpm test:visual and attach diffs if CI disagrees.

Notes for reviewers

  • The ghost hover bump changes the look of every ghost button on hover — deliberate, but it's a design-system decision, so flagging it explicitly.
  • If the hover-gate opt-out (commit 3) is contentious for touch-heavy consumers, it can be dropped independently; commits are separable.

runleveldev and others added 3 commits July 9, 2026 08:59
Tailwind v4 preflight resets buttons to cursor: default, so Button lost
the clickable affordance (QuickAction already opts back in with
cursor-pointer; this brings Button to parity).

Ghost's hover tint moves one token step (neutral-100 -> neutral-200,
dark neutral-800 -> neutral-700, actives follow): neutral-100 on a
white page is a ~4% delta most displays cannot show, so the hover state
read as "nothing happens" and users only noticed feedback via the
active state on click.
A polymorphic link that reuses buttonVariants, replacing the
<Link><Button/></Link> anti-pattern (invalid interactive-inside-
interactive HTML, double tab stop, button cursor hiding the link
pointer). Renders <a href> by default; SPA consumers pass their
router's link via `as` (react-router, Next.js) to keep client-side
navigation. Keeps native link affordances: middle-click/Ctrl+click to
a new tab, copy link address, correct assistive-technology semantics.

Deliberately a separate component rather than an `as`/`asChild` prop
on Button: links have no disabled/loading semantics, so each component
keeps an honest API.
Tailwind v4 gates the built-in hover variant behind
@media (hover: hover). Browsers in VMs, remote desktops, and some
touchscreen laptops misreport pointer capability as hover-incapable,
silently disabling every hover style in the compiled CSS while :hover
itself still matches mouse movement. MIE apps are mouse-first, so opt
out of the capability gate via @custom-variant, next to the existing
dark variant. Tradeoff is the standard pre-v4 behavior: hover styles
can linger after a tap on touch devices.
Copilot AI review requested due to automatic review settings July 9, 2026 13:01
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: a6a0ed5
Status: ✅  Deploy successful!
Preview URL: https://0d14faf2.ui-6d0.pages.dev
Branch Preview URL: https://button-affordance-and-button.ui-6d0.pages.dev

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves interaction affordances in the design system by restoring a pointer cursor and a more perceptible hover state for Button, adding a ButtonLink component for navigation without nested interactive elements, and ensuring hover: styles apply even when Tailwind v4’s hover capability gating would otherwise suppress them.

Changes:

  • Adjust Button base/ghost styling (cursor-pointer, stronger ghost hover/active tokens).
  • Add ButtonLink (polymorphic as support) with Storybook autodocs, unit tests, barrel export, and tsup entry.
  • Override Tailwind v4 hover gating via a custom hover variant in base styles.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tsup.config.ts Adds tsup entrypoint for components/ButtonLink.
src/styles/base.css Defines @custom-variant hover (&:hover); to apply hover styles unconditionally.
src/index.ts Re-exports ButtonLink from the package root.
src/components/ButtonLink/index.ts Component barrel export for ButtonLink.
src/components/ButtonLink/ButtonLink.tsx Implements ButtonLink using buttonVariants, icons, and as rendering.
src/components/ButtonLink/ButtonLink.test.tsx Adds unit tests for rendering, class application/merging, as, icons, and ref forwarding.
src/components/ButtonLink/ButtonLink.stories.tsx Adds autodocs stories demonstrating variants, icons, and router-link usage.
src/components/Button/Button.tsx Restores pointer cursor and increases ghost hover/active contrast (incl. dark mode).

Comment on lines +34 to +41
// One token step stronger than before: neutral-100 on a white page
// is a ~4% delta most displays can't show, so hover read as
// "nothing happens" and users only saw the active state on click.
'hover:bg-neutral-200',
'active:bg-neutral-300',
'dark:text-neutral-400',
'dark:hover:bg-neutral-800',
'dark:active:bg-neutral-700',
'dark:hover:bg-neutral-700',
'dark:active:bg-neutral-600',
Comment on lines +124 to +125
target="_blank"
rel="noreferrer"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants