From 154f9b38bce10fa4afdee79195f3105f95b26c83 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 14:02:13 -0400 Subject: [PATCH 1/2] feat(ui): fade Mosaic Button icons until hover Each variant x color cell sets --_cl-icon-color to an opaque faded form of its own text color, lifting to the full color on hover and while a disclosure trigger is open. Icon reads the var with a currentColor fallback, and MenuTrigger renders the Icon component instead of the raw registry glyph so its ellipsis participates. --- .changeset/mosaic-button-icon-color.md | 2 + .../mosaic/components/button/button.styles.ts | 110 ++++++++++++++++++ .../src/mosaic/components/icon/icon.styles.ts | 14 ++- .../src/mosaic/components/menu/menu.styles.ts | 5 - .../ui/src/mosaic/components/menu/menu.tsx | 6 +- 5 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 .changeset/mosaic-button-icon-color.md diff --git a/.changeset/mosaic-button-icon-color.md b/.changeset/mosaic-button-icon-color.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-button-icon-color.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index f09d9d62b6e..37a7bbfebbf 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -36,6 +36,18 @@ const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, $ const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`; const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`; +// An icon rests a step below its label and comes up to it on hover, so the label leads and the icon +// reads as supporting. Each is an opaque faded form of the cell's own text color, not an alpha of +// it: a translucent icon would pick up whatever sits behind the button and drift per surface. +// +// The achromatic foregrounds (`primary`, `neutral-foreground`) share the house faded gray. The two +// that carry hue fade toward the tint that belongs to them, and the light-on-fill pairs fade toward +// their own fill, which is the only backdrop they can ever sit on. +const iconFadedNeutral = colorVars['--cl-color-neutral-faded']; +const iconFadedNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-faded']} 50%)`; +const iconFadedOnPrimary = `color-mix(in oklab, ${colorVars['--cl-color-primary-foreground']}, ${colorVars['--cl-color-primary']} 40%)`; +const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative-foreground']}, ${colorVars['--cl-color-negative']} 40%)`; + // Interactive states are gated on `:enabled`: the `disabled` attribute blocks activation but // not matching, and the button stays hit-testable so `cursor: not-allowed` renders and a // wrapping tooltip still gets the pointer. Disabled keeps its resting fill and only dims. @@ -149,9 +161,22 @@ export const styles = stylex.create({ // variant × color, one entry per cell of the design matrix, keyed `-` so the // component can index directly. Each cell is self-contained so it reads — and tunes — against // the spec without tracing shared parts. +// +// `--_cl-icon-color` lives per cell rather than once in `base`: StyleX resolves a property to the +// last style that declares it, so a cell setting it would drop `base`'s hover branch wholesale +// rather than merge with it. `Icon` reads the var (`icon.styles.ts`) — StyleX can't emit a +// descendant rule, so the value crosses the element boundary as a custom property. export const variants = stylex.create({ // The pressed state stays outside the hover media query so no-hover devices still get one. 'filled-primary': { + '--_cl-icon-color': { + default: iconFadedOnPrimary, + ':enabled[data-open]': colorVars['--cl-color-primary-foreground'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-primary-foreground'], + }, + }, backgroundColor: { default: colorVars['--cl-color-primary'], ':enabled:active': primaryActive, @@ -164,6 +189,14 @@ export const variants = stylex.create({ color: colorVars['--cl-color-primary-foreground'], }, 'filled-neutral': { + '--_cl-icon-color': { + default: iconFadedNeutral, + ':enabled[data-open]': colorVars['--cl-color-neutral-foreground'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-neutral-foreground'], + }, + }, backgroundColor: { default: neutralStep0, ':enabled:active': neutralStep2, @@ -176,6 +209,14 @@ export const variants = stylex.create({ color: colorVars['--cl-color-neutral-foreground'], }, 'filled-negative': { + '--_cl-icon-color': { + default: iconFadedOnNegative, + ':enabled[data-open]': colorVars['--cl-color-negative-foreground'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-negative-foreground'], + }, + }, backgroundColor: { default: colorVars['--cl-color-negative'], ':enabled:active': negativeActive, @@ -192,6 +233,14 @@ export const variants = stylex.create({ // rises underneath it. Keeps the border opaque so it can't alpha-fade against an incoming // fill, and leaves it independently themeable. 'outline-primary': { + '--_cl-icon-color': { + default: iconFadedNeutral, + ':enabled[data-open]': colorVars['--cl-color-primary'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-primary'], + }, + }, borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', @@ -205,6 +254,14 @@ export const variants = stylex.create({ color: colorVars['--cl-color-primary'], }, 'outline-neutral': { + '--_cl-icon-color': { + default: iconFadedNeutral, + ':enabled[data-open]': colorVars['--cl-color-neutral-foreground'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-neutral-foreground'], + }, + }, borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', @@ -218,6 +275,14 @@ export const variants = stylex.create({ color: colorVars['--cl-color-neutral-foreground'], }, 'outline-negative': { + '--_cl-icon-color': { + default: iconFadedNegative, + ':enabled[data-open]': colorVars['--cl-color-negative'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-negative'], + }, + }, borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', @@ -232,6 +297,14 @@ export const variants = stylex.create({ }, 'ghost-primary': { + '--_cl-icon-color': { + default: iconFadedNeutral, + ':enabled[data-open]': colorVars['--cl-color-primary'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-primary'], + }, + }, backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, @@ -244,6 +317,14 @@ export const variants = stylex.create({ color: colorVars['--cl-color-primary'], }, 'ghost-neutral': { + '--_cl-icon-color': { + default: iconFadedNeutral, + ':enabled[data-open]': colorVars['--cl-color-neutral-foreground'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-neutral-foreground'], + }, + }, backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, @@ -258,6 +339,14 @@ export const variants = stylex.create({ // The one ghost that tints instead of graying, so its pressed step walks its own faded // fill toward the negative it carries rather than joining the gray ramp. 'ghost-negative': { + '--_cl-icon-color': { + default: iconFadedNegative, + ':enabled[data-open]': colorVars['--cl-color-negative'], + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-negative'], + }, + }, backgroundColor: { default: 'transparent', ':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, @@ -273,6 +362,13 @@ export const variants = stylex.create({ // link opts out of the box the size axis sets — it reads as text, not a control. Per-side // zeros for the same reason `shapeSquare` uses them. 'link-primary': { + '--_cl-icon-color': { + default: iconFadedNeutral, + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-primary'], + }, + }, backgroundColor: 'transparent', color: colorVars['--cl-color-primary'], paddingInlineEnd: 0, @@ -282,6 +378,13 @@ export const variants = stylex.create({ height: 'auto', }, 'link-neutral': { + '--_cl-icon-color': { + default: iconFadedNeutral, + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-neutral-foreground'], + }, + }, backgroundColor: 'transparent', color: colorVars['--cl-color-neutral-foreground'], paddingInlineEnd: 0, @@ -291,6 +394,13 @@ export const variants = stylex.create({ height: 'auto', }, 'link-negative': { + '--_cl-icon-color': { + default: iconFadedNegative, + '@media (hover: hover)': { + default: null, + ':enabled:hover': colorVars['--cl-color-negative'], + }, + }, backgroundColor: 'transparent', color: colorVars['--cl-color-negative'], paddingInlineEnd: 0, diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 5fa0676e168..806dc4d274d 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -1,9 +1,19 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../../tokens.stylex'; +import { durationVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ - base: { display: 'inline-block', flexShrink: 0 }, + base: { + // A container that wants the icon at a different strength than its label writes + // `--_cl-icon-color` (see `button.styles.ts`); everywhere else the icon inherits as before. + color: 'var(--_cl-icon-color, currentColor)', + display: 'inline-block', + flexShrink: 0, + // Transitions don't inherit, so the container's own color transition doesn't animate this. + transitionDuration: durationVars['--cl-duration-fast'], + transitionProperty: 'color', + transitionTimingFunction: 'linear', + }, }); export const sizes = stylex.create({ diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts index d97059781c6..82c60c8dfa0 100644 --- a/packages/ui/src/mosaic/components/menu/menu.styles.ts +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -107,9 +107,4 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-border'], blockSize: '1px', }, - - triggerIcon: { - height: space['4'], - width: space['4'], - }, }); diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index 9941cccfc5b..5fdca0ea9de 100644 --- a/packages/ui/src/mosaic/components/menu/menu.tsx +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -10,13 +10,11 @@ import { Menu as Primitive } from '@clerk/headless/menu'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; -import { iconRegistry } from '../../icons/registry'; import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; +import { Icon } from '../icon'; import { styles } from './menu.styles'; -const EllipsisIcon = iconRegistry.ellipsis; - export type { MenuProps, MenuSeparatorProps, MenuTriggerProps }; /** @@ -44,7 +42,7 @@ export const MenuTrigger = React.forwardRef {...mergeStyleProps(themeProps('menu-trigger'), className, style)} {...rest} > - {children ?? } + {children ?? } ); }); From 7578b25182d16505f715f25c25b39d47fdf70406 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 14:36:14 -0400 Subject: [PATCH 2/2] docs(repo): render the Button shape examples with Icon The shape story inlined a raw plus glyph, so it sat outside the icon color contract and stayed full-strength while every other icon in the story faded. --- .../swingset/src/stories/button.stories.tsx | 62 +++---------------- 1 file changed, 10 insertions(+), 52 deletions(-) diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index af32b82d31b..e2e6fefc3a2 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -145,19 +145,10 @@ export function Shapes(props: Record) { size='sm' aria-label='Add' > - - - + );