Skip to content

Commit 078f0ce

Browse files
committed
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. MenuTrigger renders the Icon component instead of the raw registry glyph so its ellipsis participates.
1 parent 69b6bea commit 078f0ce

4 files changed

Lines changed: 114 additions & 21 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/ui/src/mosaic/components/button/button.styles.ts

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ const primaryActive = `color-mix(in oklab, ${colorVars['--cl-color-primary']}, $
3636
const negativeHover = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 12%)`;
3737
const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-foreground']} 18%)`;
3838

39+
// An icon rests a step below its label and comes up to it on hover, so the label leads and the icon
40+
// reads as supporting. Each is an opaque faded form of the cell's own text color, not an alpha of
41+
// it: a translucent icon would pick up whatever sits behind the button and drift per surface.
42+
//
43+
// The achromatic foregrounds (`primary`, `neutral-foreground`) share the house faded gray. The two
44+
// that carry hue fade toward the tint that belongs to them, and the light-on-fill pairs fade toward
45+
// their own fill, which is the only backdrop they can ever sit on.
46+
const iconFadedNeutral = colorVars['--cl-color-neutral-faded'];
47+
const iconFadedNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, ${colorVars['--cl-color-negative-faded']} 50%)`;
48+
const iconFadedOnPrimary = `color-mix(in oklab, ${colorVars['--cl-color-primary-foreground']}, ${colorVars['--cl-color-primary']} 40%)`;
49+
const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negative-foreground']}, ${colorVars['--cl-color-negative']} 40%)`;
50+
3951
// Interactive states are gated on `:enabled`: the `disabled` attribute blocks activation but
4052
// not matching, and the button stays hit-testable so `cursor: not-allowed` renders and a
4153
// wrapping tooltip still gets the pointer. Disabled keeps its resting fill and only dims.
@@ -53,18 +65,6 @@ const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']},
5365

5466
export const styles = stylex.create({
5567
base: {
56-
// The icon rides at reduced strength until the button is hovered, so it reads as secondary to
57-
// the label at rest. Mixed from `currentColor` rather than set per cell: every variant×color
58-
// already carries its own foreground, so one declaration covers the whole matrix. `Icon` reads
59-
// this var (`icon.styles.ts`); StyleX can't emit a descendant rule, so the value has to cross
60-
// the element boundary as a custom property.
61-
'--_cl-icon-color': {
62-
default: `color-mix(in oklab, currentColor 60%, transparent)`,
63-
'@media (hover: hover)': {
64-
default: null,
65-
':enabled:hover': 'currentColor',
66-
},
67-
},
6868
borderColor: 'transparent',
6969
borderRadius: radiusVars['--cl-radius-md'],
7070
borderStyle: 'solid',
@@ -145,9 +145,22 @@ export const styles = stylex.create({
145145
// variant × color, one entry per cell of the design matrix, keyed `<variant>-<color>` so the
146146
// component can index directly. Each cell is self-contained so it reads — and tunes — against
147147
// the spec without tracing shared parts.
148+
//
149+
// `--_cl-icon-color` lives per cell rather than once in `base`: StyleX resolves a property to the
150+
// last style that declares it, so a cell setting it would drop `base`'s hover branch wholesale
151+
// rather than merge with it. `Icon` reads the var (`icon.styles.ts`) — StyleX can't emit a
152+
// descendant rule, so the value crosses the element boundary as a custom property.
148153
export const variants = stylex.create({
149154
// The pressed state stays outside the hover media query so no-hover devices still get one.
150155
'filled-primary': {
156+
'--_cl-icon-color': {
157+
default: iconFadedOnPrimary,
158+
':enabled[data-open]': colorVars['--cl-color-primary-foreground'],
159+
'@media (hover: hover)': {
160+
default: null,
161+
':enabled:hover': colorVars['--cl-color-primary-foreground'],
162+
},
163+
},
151164
backgroundColor: {
152165
default: colorVars['--cl-color-primary'],
153166
':enabled:active': primaryActive,
@@ -160,6 +173,14 @@ export const variants = stylex.create({
160173
color: colorVars['--cl-color-primary-foreground'],
161174
},
162175
'filled-neutral': {
176+
'--_cl-icon-color': {
177+
default: iconFadedNeutral,
178+
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
179+
'@media (hover: hover)': {
180+
default: null,
181+
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
182+
},
183+
},
163184
backgroundColor: {
164185
default: neutralStep0,
165186
':enabled:active': neutralStep2,
@@ -172,6 +193,14 @@ export const variants = stylex.create({
172193
color: colorVars['--cl-color-neutral-foreground'],
173194
},
174195
'filled-negative': {
196+
'--_cl-icon-color': {
197+
default: iconFadedOnNegative,
198+
':enabled[data-open]': colorVars['--cl-color-negative-foreground'],
199+
'@media (hover: hover)': {
200+
default: null,
201+
':enabled:hover': colorVars['--cl-color-negative-foreground'],
202+
},
203+
},
175204
backgroundColor: {
176205
default: colorVars['--cl-color-negative'],
177206
':enabled:active': negativeActive,
@@ -188,6 +217,14 @@ export const variants = stylex.create({
188217
// rises underneath it. Keeps the border opaque so it can't alpha-fade against an incoming
189218
// fill, and leaves it independently themeable.
190219
'outline-primary': {
220+
'--_cl-icon-color': {
221+
default: iconFadedNeutral,
222+
':enabled[data-open]': colorVars['--cl-color-primary'],
223+
'@media (hover: hover)': {
224+
default: null,
225+
':enabled:hover': colorVars['--cl-color-primary'],
226+
},
227+
},
191228
borderColor: colorVars['--cl-color-border'],
192229
backgroundColor: {
193230
default: 'transparent',
@@ -201,6 +238,14 @@ export const variants = stylex.create({
201238
color: colorVars['--cl-color-primary'],
202239
},
203240
'outline-neutral': {
241+
'--_cl-icon-color': {
242+
default: iconFadedNeutral,
243+
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
244+
'@media (hover: hover)': {
245+
default: null,
246+
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
247+
},
248+
},
204249
borderColor: colorVars['--cl-color-border'],
205250
backgroundColor: {
206251
default: 'transparent',
@@ -214,6 +259,14 @@ export const variants = stylex.create({
214259
color: colorVars['--cl-color-neutral-foreground'],
215260
},
216261
'outline-negative': {
262+
'--_cl-icon-color': {
263+
default: iconFadedNegative,
264+
':enabled[data-open]': colorVars['--cl-color-negative'],
265+
'@media (hover: hover)': {
266+
default: null,
267+
':enabled:hover': colorVars['--cl-color-negative'],
268+
},
269+
},
217270
borderColor: colorVars['--cl-color-border'],
218271
backgroundColor: {
219272
default: 'transparent',
@@ -228,6 +281,14 @@ export const variants = stylex.create({
228281
},
229282

230283
'ghost-primary': {
284+
'--_cl-icon-color': {
285+
default: iconFadedNeutral,
286+
':enabled[data-open]': colorVars['--cl-color-primary'],
287+
'@media (hover: hover)': {
288+
default: null,
289+
':enabled:hover': colorVars['--cl-color-primary'],
290+
},
291+
},
231292
backgroundColor: {
232293
default: 'transparent',
233294
':enabled:active': neutralStep1,
@@ -240,6 +301,14 @@ export const variants = stylex.create({
240301
color: colorVars['--cl-color-primary'],
241302
},
242303
'ghost-neutral': {
304+
'--_cl-icon-color': {
305+
default: iconFadedNeutral,
306+
':enabled[data-open]': colorVars['--cl-color-neutral-foreground'],
307+
'@media (hover: hover)': {
308+
default: null,
309+
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
310+
},
311+
},
243312
backgroundColor: {
244313
default: 'transparent',
245314
':enabled:active': neutralStep1,
@@ -254,6 +323,14 @@ export const variants = stylex.create({
254323
// The one ghost that tints instead of graying, so its pressed step walks its own faded
255324
// fill toward the negative it carries rather than joining the gray ramp.
256325
'ghost-negative': {
326+
'--_cl-icon-color': {
327+
default: iconFadedNegative,
328+
':enabled[data-open]': colorVars['--cl-color-negative'],
329+
'@media (hover: hover)': {
330+
default: null,
331+
':enabled:hover': colorVars['--cl-color-negative'],
332+
},
333+
},
257334
backgroundColor: {
258335
default: 'transparent',
259336
':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
@@ -269,6 +346,13 @@ export const variants = stylex.create({
269346
// link opts out of the box the size axis sets — it reads as text, not a control. Per-side
270347
// zeros for the same reason `shapeSquare` uses them.
271348
'link-primary': {
349+
'--_cl-icon-color': {
350+
default: iconFadedNeutral,
351+
'@media (hover: hover)': {
352+
default: null,
353+
':enabled:hover': colorVars['--cl-color-primary'],
354+
},
355+
},
272356
backgroundColor: 'transparent',
273357
color: colorVars['--cl-color-primary'],
274358
paddingInlineEnd: 0,
@@ -278,6 +362,13 @@ export const variants = stylex.create({
278362
height: 'auto',
279363
},
280364
'link-neutral': {
365+
'--_cl-icon-color': {
366+
default: iconFadedNeutral,
367+
'@media (hover: hover)': {
368+
default: null,
369+
':enabled:hover': colorVars['--cl-color-neutral-foreground'],
370+
},
371+
},
281372
backgroundColor: 'transparent',
282373
color: colorVars['--cl-color-neutral-foreground'],
283374
paddingInlineEnd: 0,
@@ -287,6 +378,13 @@ export const variants = stylex.create({
287378
height: 'auto',
288379
},
289380
'link-negative': {
381+
'--_cl-icon-color': {
382+
default: iconFadedNegative,
383+
'@media (hover: hover)': {
384+
default: null,
385+
':enabled:hover': colorVars['--cl-color-negative'],
386+
},
387+
},
290388
backgroundColor: 'transparent',
291389
color: colorVars['--cl-color-negative'],
292390
paddingInlineEnd: 0,

packages/ui/src/mosaic/components/menu/menu.styles.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,4 @@ export const styles = stylex.create({
107107
backgroundColor: colorVars['--cl-color-border'],
108108
blockSize: '1px',
109109
},
110-
111-
triggerIcon: {
112-
height: space['4'],
113-
width: space['4'],
114-
},
115110
});

packages/ui/src/mosaic/components/menu/menu.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@ import { Menu as Primitive } from '@clerk/headless/menu';
1010
import * as stylex from '@stylexjs/stylex';
1111
import React from 'react';
1212

13-
import { iconRegistry } from '../../icons/registry';
1413
import { mergeStyleProps, themeProps } from '../../props';
1514
import { Button } from '../button';
15+
import { Icon } from '../icon';
1616
import { styles } from './menu.styles';
1717

18-
const EllipsisIcon = iconRegistry.ellipsis;
19-
2018
export type { MenuProps, MenuSeparatorProps, MenuTriggerProps };
2119

2220
/**
@@ -44,7 +42,7 @@ export const MenuTrigger = React.forwardRef<HTMLButtonElement, MenuTriggerProps>
4442
{...mergeStyleProps(themeProps('menu-trigger'), className, style)}
4543
{...rest}
4644
>
47-
{children ?? <EllipsisIcon {...stylex.props(styles.triggerIcon)} />}
45+
{children ?? <Icon name='ellipsis' />}
4846
</Primitive.Trigger>
4947
);
5048
});

0 commit comments

Comments
 (0)