From 70c65329c61433a7d6f63548566bb8cb4825485a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 31 Jul 2026 09:29:59 -0600 Subject: [PATCH 1/2] feat(ui): grow the Mosaic Button touch target without growing the button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a coarse pointer the button floored at `--cl-target-coarse` via `min-height`, which made every button visibly taller than its `size`. Grow an invisible `::after` overlay instead, so the hit area reaches the target while the rendered box stays put. `overflow: hidden` comes off `base` — the label span carries its own, and the overlay has to escape the box to be hit-testable. Adds `touchTarget` (default `true`) to opt out where neighbors sit close enough that the overlays would overlap. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/rare-pumas-repeat.md | 5 +++ packages/swingset/src/stories/button.mdx | 20 ++++++++++++ .../swingset/src/stories/button.stories.tsx | 2 ++ .../mosaic/components/button/button.styles.ts | 32 ++++++++++++++----- .../mosaic/components/button/button.test.tsx | 30 +++++++++++++++++ .../src/mosaic/components/button/button.tsx | 14 ++++++-- 6 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 .changeset/rare-pumas-repeat.md diff --git a/.changeset/rare-pumas-repeat.md b/.changeset/rare-pumas-repeat.md new file mode 100644 index 00000000000..c86599792ac --- /dev/null +++ b/.changeset/rare-pumas-repeat.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Mosaic `Button` no longer grows on touch devices to reach the recommended hit target. It keeps the size its `size` prop gives it and extends only the area that responds to a tap. Add `touchTarget={false}` to turn the larger tap area off where buttons sit close enough that theirs would overlap. diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index 5c447778fc8..e0ce70678e9 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -122,3 +122,23 @@ which color and variant it is stays legible while it's unavailable. Hover and pr by the styles, not by `pointer-events`: the button stays hit-testable, which is what makes `cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled. That tooltip is worth adding — a disabled control with no explanation is a dead end. + +### Touch target + +Every size is shorter than the 44px a fingertip needs, so under `pointer: coarse` the button grows +its hit area with an invisible overlay — the rendered size doesn't change, and nothing shifts on a +mouse. Default shape grows on the block axis only; square and circle grow on both, being as narrow +as they are short. `link` is text, not a control, and never takes one. + +Opt out with `touchTarget={false}` where buttons sit close enough for the overlays to overlap — a +dense icon toolbar, a tight stack. Overlap isn't symmetrical: the later sibling's overlay covers +the edge of the one before it, so a tap there activates the wrong button. + +```tsx + +``` diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index 03fc14af232..af32b82d31b 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -22,6 +22,7 @@ export const meta: StoryMeta = { size: { sm: {}, md: {}, lg: {} }, shape: { default: {}, square: {}, circle: {} }, fullWidth: { true: {}, false: {} }, + touchTarget: { true: {}, false: {} }, }, _defaultVariants: { color: 'primary', @@ -29,6 +30,7 @@ export const meta: StoryMeta = { size: 'md', shape: 'default', fullWidth: false, + touchTarget: true, }, }, }; diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 25e6c7f99f3..f09d9d62b6e 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -62,8 +62,6 @@ export const styles = stylex.create({ default: 'none', ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, }, - // The size axis fixes the height, so overflowing content clips instead of spilling out. - overflow: 'hidden', alignItems: 'center', // Strips UA control styling so what's below is the whole appearance, not an override. appearance: 'none', @@ -113,16 +111,34 @@ export const styles = stylex.create({ paddingInlineStart: 0, }, - // Under a coarse pointer the control floors at the target size. Its own atom rather than a - // per-size override: the floor is one physical constant, and `link` — text, not a control — - // opts out by not receiving it. `minHeight` leaves the fixed height in charge otherwise. + // Under a coarse pointer the hit area floors at the target size — grown by an overlay + // rather than by `min-height`, so the button keeps the size its axis gives it and only the + // region answering a tap gets bigger. Its own atom rather than a per-size override: the + // floor is one physical constant, and `link` — text, not a control — opts out by not + // receiving it. + // + // The insets resolve against the button's own box, so one expression covers every size: + // the overlay lands at exactly the target height whatever the shortfall, and clamps to the + // button's bounds once the control is already past the floor. `position` is scoped to the + // media query too — a fine pointer generates no overlay, so it shouldn't take on the + // stacking change either. touchTarget: { - minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + position: { default: null, '@media (pointer: coarse)': 'relative' }, + '::after': { + insetBlock: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`, + // Text buttons are already past the floor inline — growing that axis too would only + // reach into a neighbor's space for nothing. + insetInline: 0, + content: { default: null, '@media (pointer: coarse)': '""' }, + position: 'absolute', + }, }, - // Icon buttons are square, so the floor has to reach the inline axis too or the target + // Icon buttons are square, so the floor has to reach the inline axis as well or the region // ends up tall and narrow. touchTargetIcon: { - minWidth: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + '::after': { + insetInline: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`, + }, }, // state / modifiers diff --git a/packages/ui/src/mosaic/components/button/button.test.tsx b/packages/ui/src/mosaic/components/button/button.test.tsx index 163b799b8bb..6fd7f0162b6 100644 --- a/packages/ui/src/mosaic/components/button/button.test.tsx +++ b/packages/ui/src/mosaic/components/button/button.test.tsx @@ -30,6 +30,36 @@ describe('Mosaic Button', () => { expect(button).toBeEnabled(); }); + // The touch target is an overlay with no attribute of its own, so these compare the atoms + // the element ends up with rather than a reflected variant. + it('drops the touch-target atoms when the prop is off', () => { + const { rerender } = render(); + const on = screen.getByRole('button').className.split(' '); + rerender(); + const off = screen.getByRole('button').className.split(' '); + expect(off.length).toBeLessThan(on.length); + expect(off.every(atom => on.includes(atom))).toBe(true); + }); + + it('leaves the touch target off a link, which is text rather than a control', () => { + const { rerender } = render(); + const on = screen.getByRole('button').className; + rerender( + , + ); + expect(screen.getByRole('button').className).toBe(on); + }); + + it('keeps the touch-target prop off the element', () => { + render(); + expect(screen.getByRole('button')).not.toHaveAttribute('touchtarget'); + }); + it('wires variant props and consumer className/style through to the element', () => { render(