Skip to content

Commit 6e6ae21

Browse files
authored
fix(s2): Make gradient buttons have an animated transition (#7585)
* fix(s2): Make gradient buttons have an animated transition * fix render prop overrides
1 parent 82a4de5 commit 6e6ae21

File tree

4 files changed

+168
-85
lines changed

4 files changed

+168
-85
lines changed

packages/@react-spectrum/s2/src/Button.tsx

Lines changed: 107 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -138,24 +138,6 @@ const button = style<ButtonRenderProps & ButtonStyleProps & {isStaticColor: bool
138138
isDisabled: 'GrayText'
139139
}
140140
},
141-
backgroundImage: {
142-
variant: {
143-
premium: {
144-
default: linearGradient('96deg', ['fuchsia-900', 0], ['indigo-900', 66], ['blue-900', 100]),
145-
isHovered: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
146-
isPressed: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
147-
isFocusVisible: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100])
148-
},
149-
genai: {
150-
default: linearGradient('96deg', ['red-900', 0], ['magenta-900', 33], ['indigo-900', 100]),
151-
isHovered: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
152-
isPressed: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
153-
isFocusVisible: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100])
154-
}
155-
},
156-
isDisabled: 'none',
157-
forcedColors: 'none'
158-
},
159141
backgroundColor: {
160142
fillStyle: {
161143
fill: {
@@ -296,6 +278,42 @@ const button = style<ButtonRenderProps & ButtonStyleProps & {isStaticColor: bool
296278
disableTapHighlight: true
297279
}, getAllowedOverrides());
298280

281+
// Put the gradient background on a separate element from the button to work around a Safari
282+
// bug where transitions of custom properties cause layout flickering if any properties use rems. 🤣
283+
// https://bugs.webkit.org/show_bug.cgi?id=285622
284+
const gradient = style({
285+
position: 'absolute',
286+
inset: 0,
287+
zIndex: -1,
288+
transition: 'default',
289+
borderRadius: '[inherit]',
290+
backgroundImage: {
291+
variant: {
292+
premium: {
293+
default: linearGradient('to bottom right', ['fuchsia-900', 0], ['indigo-900', 66], ['blue-900', 100]),
294+
isHovered: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
295+
isPressed: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
296+
isFocusVisible: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100])
297+
},
298+
genai: {
299+
default: linearGradient('to bottom right', ['red-900', 0], ['magenta-900', 33], ['indigo-900', 100]),
300+
isHovered: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
301+
isPressed: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
302+
isFocusVisible: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100])
303+
}
304+
},
305+
isDisabled: 'none',
306+
forcedColors: 'none'
307+
},
308+
// Force gradient colors to remain static between light and dark theme.
309+
colorScheme: {
310+
variant: {
311+
premium: 'light',
312+
genai: 'light'
313+
}
314+
}
315+
});
316+
299317
/**
300318
* Buttons allow users to perform an action.
301319
* They have multiple styles for various needs, and are ideal for calling attention to
@@ -350,65 +368,79 @@ export const Button = forwardRef(function Button(props: ButtonProps, ref: Focusa
350368
staticColor,
351369
isStaticColor: !!staticColor
352370
}, props.styles)}>
353-
<Provider
354-
values={[
355-
[SkeletonContext, null],
356-
[TextContext, {
357-
styles: style({
358-
paddingY: '--labelPadding',
359-
order: 1,
360-
opacity: {
361-
default: 1,
362-
isProgressVisible: 0
363-
}
364-
})({isProgressVisible}),
365-
// @ts-ignore data-attributes allowed on all JSX elements, but adding to DOMProps has been problematic in the past
366-
'data-rsp-slot': 'text'
367-
}],
368-
[IconContext, {
369-
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
370-
styles: style({
371-
size: fontRelative(20),
372-
marginStart: '--iconMargin',
373-
flexShrink: 0,
374-
opacity: {
375-
default: 1,
376-
isProgressVisible: 0
377-
}
378-
})({isProgressVisible})
379-
}]
380-
]}>
381-
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
382-
{isPending &&
383-
<div
384-
className={style({
385-
position: 'absolute',
386-
top: '[50%]',
387-
left: '[50%]',
388-
transform: 'translate(-50%, -50%)',
389-
opacity: {
390-
default: 0,
391-
isProgressVisible: 1
392-
}
393-
})({isProgressVisible, isPending})}>
394-
<ProgressCircle
395-
isIndeterminate
396-
aria-label={stringFormatter.format('button.pending')}
397-
size="S"
398-
staticColor={staticColor}
399-
styles={style({
400-
size: {
371+
{(renderProps) => (<>
372+
{variant === 'genai' || variant === 'premium'
373+
? (
374+
<span
375+
className={gradient({
376+
...renderProps,
377+
// Retain hover styles when an overlay is open.
378+
isHovered: renderProps.isHovered || overlayTriggerState?.isOpen || false,
379+
isDisabled: renderProps.isDisabled || isProgressVisible,
380+
variant
381+
})} />
382+
)
383+
: null}
384+
<Provider
385+
values={[
386+
[SkeletonContext, null],
387+
[TextContext, {
388+
styles: style({
389+
paddingY: '--labelPadding',
390+
order: 1,
391+
opacity: {
392+
default: 1,
393+
isProgressVisible: 0
394+
}
395+
})({isProgressVisible}),
396+
// @ts-ignore data-attributes allowed on all JSX elements, but adding to DOMProps has been problematic in the past
397+
'data-rsp-slot': 'text'
398+
}],
399+
[IconContext, {
400+
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
401+
styles: style({
402+
size: fontRelative(20),
403+
marginStart: '--iconMargin',
404+
flexShrink: 0,
405+
opacity: {
406+
default: 1,
407+
isProgressVisible: 0
408+
}
409+
})({isProgressVisible})
410+
}]
411+
]}>
412+
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
413+
{isPending &&
414+
<div
415+
className={style({
416+
position: 'absolute',
417+
top: '[50%]',
418+
left: '[50%]',
419+
transform: 'translate(-50%, -50%)',
420+
opacity: {
421+
default: 0,
422+
isProgressVisible: 1
423+
}
424+
})({isProgressVisible, isPending})}>
425+
<ProgressCircle
426+
isIndeterminate
427+
aria-label={stringFormatter.format('button.pending')}
428+
size="S"
429+
staticColor={staticColor}
430+
styles={style({
401431
size: {
402-
S: 14,
403-
M: 18,
404-
L: 20,
405-
XL: 24
432+
size: {
433+
S: 14,
434+
M: 18,
435+
L: 20,
436+
XL: 24
437+
}
406438
}
407-
}
408-
})({size})} />
409-
</div>
410-
}
411-
</Provider>
439+
})({size})} />
440+
</div>
441+
}
442+
</Provider>
443+
</>)}
412444
</RACButton>
413445
);
414446
});

packages/@react-spectrum/s2/style/spectrum-theme.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ArbitraryValue, CSSValue, PropertyValueMap} from './types';
13+
import {ArbitraryValue, CSSProperties, CSSValue, PropertyValueMap} from './types';
1414
import {autoStaticColor, colorScale, colorToken, fontSizeToken, generateOverlayColorScale, getToken, simpleColorScale, weirdColorToken} from './tokens' with {type: 'macro'};
1515
import {Color, createArbitraryProperty, createColorProperty, createMappedProperty, createRenamedProperty, createSizingProperty, createTheme, parseArbitraryValue} from './style-macro';
1616
import type * as CSS from 'csstype';
@@ -111,8 +111,35 @@ export function colorMix(a: SpectrumColor, b: SpectrumColor, percent: number): `
111111
return `[color-mix(in srgb, ${parseColor(a)}, ${parseColor(b)} ${percent}%)]`;
112112
}
113113

114-
export function linearGradient(angle: string, ...tokens: [SpectrumColor, number][]): string {
115-
return `linear-gradient(${angle}, ${tokens.map(([color, stop]) => `${parseColor(color)} ${stop}%`)})`;
114+
interface LinearGradient {
115+
type: 'linear-gradient',
116+
angle: string,
117+
stops: [SpectrumColor, number][]
118+
}
119+
120+
export function linearGradient(this: MacroContext | void, angle: string, ...tokens: [SpectrumColor, number][]): [LinearGradient] {
121+
// Generate @property rules for each gradient stop color. This allows the gradient to be animated.
122+
let propertyDefinitions: string[] = [];
123+
for (let i = 0; i < tokens.length; i++) {
124+
propertyDefinitions.push(`@property --g${i} {
125+
syntax: '<color>';
126+
initial-value: #0000;
127+
inherits: false;
128+
}`);
129+
}
130+
131+
if (this && typeof this.addAsset === 'function') {
132+
this.addAsset({
133+
type: 'css',
134+
content: propertyDefinitions.join('\n\n')
135+
});
136+
}
137+
138+
return [{
139+
type: 'linear-gradient',
140+
angle,
141+
stops: tokens
142+
}];
116143
}
117144

118145
function generateSpacing<K extends number[]>(px: K): {[P in K[number]]: string} {
@@ -320,8 +347,10 @@ let gridTrackSize = (value: GridTrackSize) => {
320347
};
321348

322349
const transitionProperty = {
323-
default: 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter',
324-
colors: 'color, background-color, border-color, text-decoration-color, fill, stroke',
350+
// var(--gp) is generated by the backgroundImage property when setting a gradient.
351+
// It includes a list of all of the custom properties used for each color stop.
352+
default: 'color, background-color, var(--gp), border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter',
353+
colors: 'color, background-color, var(--gp), border-color, text-decoration-color, fill, stroke',
325354
opacity: 'opacity',
326355
shadow: 'box-shadow',
327356
transform: 'transform, translate, scale, rotate',
@@ -802,7 +831,29 @@ export const style = createTheme({
802831
borderBottomEndRadius: createRenamedProperty('borderEndEndRadius', radius),
803832
forcedColorAdjust: ['auto', 'none'] as const,
804833
colorScheme: ['light', 'dark', 'light dark'] as const,
805-
backgroundImage: createArbitraryProperty<string>(),
834+
backgroundImage: createArbitraryProperty<string | [LinearGradient]>((value, property) => {
835+
if (typeof value === 'string') {
836+
return {[property]: value};
837+
} else if (Array.isArray(value) && value[0]?.type === 'linear-gradient') {
838+
let values: CSSProperties = {
839+
[property]: `linear-gradient(${value[0].angle}, ${value[0].stops.map(([, stop], i) => `var(--g${i}) ${stop}%`)})`
840+
};
841+
842+
// Create a CSS var for each color stop so the gradient can be transitioned.
843+
// These are registered via @property in the `linearGradient` macro.
844+
let properties: string[] = [];
845+
value[0].stops.forEach(([color], i) => {
846+
properties.push(`--g${i}`);
847+
values[`--g${i}`] = parseColor(color);
848+
});
849+
850+
// This is used by transition-property so we automatically transition all of the color stops.
851+
values['--gp'] = properties.join(', ');
852+
return values;
853+
} else {
854+
throw new Error('Unexpected backgroundImage value: ' + JSON.stringify(value));
855+
}
856+
}),
806857
// TODO: do we need separate x and y properties?
807858
backgroundPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'] as const,
808859
backgroundSize: ['auto', 'cover', 'contain'] as const,

packages/@react-spectrum/s2/style/style-macro.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212

1313
import type {Condition, CSSProperties, CSSValue, CustomValue, PropertyFunction, PropertyValueDefinition, PropertyValueMap, RenderProps, ShorthandProperty, StyleFunction, StyleValue, Theme, ThemeProperties, Value} from './types';
1414

15-
let defaultArbitraryProperty = <T extends Value>(value: T, property: string) => ({[property]: value} as CSSProperties);
16-
export function createArbitraryProperty<T extends Value>(fn: (value: T, property: string) => CSSProperties = defaultArbitraryProperty): PropertyFunction<T> {
15+
let defaultArbitraryProperty = <T>(value: T, property: string) => ({[property]: value} as CSSProperties);
16+
export function createArbitraryProperty<T>(fn: (value: T, property: string) => CSSProperties = defaultArbitraryProperty): PropertyFunction<T> {
1717
return (value, property) => {
18-
let selector = Array.isArray(value) ? generateArbitraryValueSelector(value.map(v => String(v)).join('')) : generateArbitraryValueSelector(String(value));
18+
let selector = Array.isArray(value) ? generateArbitraryValueSelector(value.map(v => JSON.stringify(v)).join('')) : generateArbitraryValueSelector(JSON.stringify(value));
1919
return {default: [fn(value, property), selector]};
2020
};
2121
}

packages/@react-spectrum/s2/style/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type CSSProperties = CSS.Properties & {
2525
[k: CustomProperty]: CSSValue
2626
};
2727

28-
export type PropertyFunction<T extends Value> = (value: T, property: string) => PropertyValueDefinition<[CSSProperties, string]>;
28+
export type PropertyFunction<T> = (value: T, property: string) => PropertyValueDefinition<[CSSProperties, string]>;
2929

3030
export type ShorthandProperty<T> = (value: T) => {[name: string]: Value};
3131

0 commit comments

Comments
 (0)