Skip to content

fix(s2): Make gradient buttons have an animated transition #7585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 107 additions & 75 deletions packages/@react-spectrum/s2/src/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,24 +138,6 @@ const button = style<ButtonRenderProps & ButtonStyleProps & {isStaticColor: bool
isDisabled: 'GrayText'
}
},
backgroundImage: {
variant: {
premium: {
default: linearGradient('96deg', ['fuchsia-900', 0], ['indigo-900', 66], ['blue-900', 100]),
isHovered: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
isPressed: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
isFocusVisible: linearGradient('96deg', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100])
},
genai: {
default: linearGradient('96deg', ['red-900', 0], ['magenta-900', 33], ['indigo-900', 100]),
isHovered: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
isPressed: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
isFocusVisible: linearGradient('96deg', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100])
}
},
isDisabled: 'none',
forcedColors: 'none'
},
backgroundColor: {
fillStyle: {
fill: {
Expand Down Expand Up @@ -296,6 +278,42 @@ const button = style<ButtonRenderProps & ButtonStyleProps & {isStaticColor: bool
disableTapHighlight: true
}, getAllowedOverrides());

// Put the gradient background on a separate element from the button to work around a Safari
// bug where transitions of custom properties cause layout flickering if any properties use rems. 🤣
// https://bugs.webkit.org/show_bug.cgi?id=285622
const gradient = style({
position: 'absolute',
inset: 0,
zIndex: -1,
transition: 'default',
borderRadius: '[inherit]',
backgroundImage: {
variant: {
premium: {
default: linearGradient('to bottom right', ['fuchsia-900', 0], ['indigo-900', 66], ['blue-900', 100]),
isHovered: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
isPressed: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100]),
isFocusVisible: linearGradient('to bottom right', ['fuchsia-1000', 0], ['indigo-1000', 66], ['blue-1000', 100])
},
genai: {
default: linearGradient('to bottom right', ['red-900', 0], ['magenta-900', 33], ['indigo-900', 100]),
isHovered: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
isPressed: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100]),
isFocusVisible: linearGradient('to bottom right', ['red-1000', 0], ['magenta-1000', 33], ['indigo-1000', 100])
}
},
isDisabled: 'none',
forcedColors: 'none'
},
// Force gradient colors to remain static between light and dark theme.
colorScheme: {
variant: {
premium: 'light',
genai: 'light'
}
}
});

/**
* Buttons allow users to perform an action.
* They have multiple styles for various needs, and are ideal for calling attention to
Expand Down Expand Up @@ -350,65 +368,79 @@ export const Button = forwardRef(function Button(props: ButtonProps, ref: Focusa
staticColor,
isStaticColor: !!staticColor
}, props.styles)}>
<Provider
values={[
[SkeletonContext, null],
[TextContext, {
styles: style({
paddingY: '--labelPadding',
order: 1,
opacity: {
default: 1,
isProgressVisible: 0
}
})({isProgressVisible}),
// @ts-ignore data-attributes allowed on all JSX elements, but adding to DOMProps has been problematic in the past
'data-rsp-slot': 'text'
}],
[IconContext, {
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
styles: style({
size: fontRelative(20),
marginStart: '--iconMargin',
flexShrink: 0,
opacity: {
default: 1,
isProgressVisible: 0
}
})({isProgressVisible})
}]
]}>
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
{isPending &&
<div
className={style({
position: 'absolute',
top: '[50%]',
left: '[50%]',
transform: 'translate(-50%, -50%)',
opacity: {
default: 0,
isProgressVisible: 1
}
})({isProgressVisible, isPending})}>
<ProgressCircle
isIndeterminate
aria-label={stringFormatter.format('button.pending')}
size="S"
staticColor={staticColor}
styles={style({
size: {
{(renderProps) => (<>
{variant === 'genai' || variant === 'premium'
? (
<span
className={gradient({
...renderProps,
// Retain hover styles when an overlay is open.
isHovered: renderProps.isHovered || overlayTriggerState?.isOpen || false,
isDisabled: renderProps.isDisabled || isProgressVisible,
variant
})} />
)
: null}
<Provider
values={[
[SkeletonContext, null],
[TextContext, {
styles: style({
paddingY: '--labelPadding',
order: 1,
opacity: {
default: 1,
isProgressVisible: 0
}
})({isProgressVisible}),
// @ts-ignore data-attributes allowed on all JSX elements, but adding to DOMProps has been problematic in the past
'data-rsp-slot': 'text'
}],
[IconContext, {
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
styles: style({
size: fontRelative(20),
marginStart: '--iconMargin',
flexShrink: 0,
opacity: {
default: 1,
isProgressVisible: 0
}
})({isProgressVisible})
}]
]}>
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
{isPending &&
<div
className={style({
position: 'absolute',
top: '[50%]',
left: '[50%]',
transform: 'translate(-50%, -50%)',
opacity: {
default: 0,
isProgressVisible: 1
}
})({isProgressVisible, isPending})}>
<ProgressCircle
isIndeterminate
aria-label={stringFormatter.format('button.pending')}
size="S"
staticColor={staticColor}
styles={style({
size: {
S: 14,
M: 18,
L: 20,
XL: 24
size: {
S: 14,
M: 18,
L: 20,
XL: 24
}
}
}
})({size})} />
</div>
}
</Provider>
})({size})} />
</div>
}
</Provider>
</>)}
</RACButton>
);
});
Expand Down
63 changes: 57 additions & 6 deletions packages/@react-spectrum/s2/style/spectrum-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

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

export function linearGradient(angle: string, ...tokens: [SpectrumColor, number][]): string {
return `linear-gradient(${angle}, ${tokens.map(([color, stop]) => `${parseColor(color)} ${stop}%`)})`;
interface LinearGradient {
type: 'linear-gradient',
angle: string,
stops: [SpectrumColor, number][]
}

export function linearGradient(this: MacroContext | void, angle: string, ...tokens: [SpectrumColor, number][]): [LinearGradient] {
// Generate @property rules for each gradient stop color. This allows the gradient to be animated.
let propertyDefinitions: string[] = [];
for (let i = 0; i < tokens.length; i++) {
propertyDefinitions.push(`@property --g${i} {
syntax: '<color>';
initial-value: #0000;
inherits: false;
}`);
}

if (this && typeof this.addAsset === 'function') {
this.addAsset({
type: 'css',
content: propertyDefinitions.join('\n\n')
});
}

return [{
type: 'linear-gradient',
angle,
stops: tokens
}];
}

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

const transitionProperty = {
default: 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter',
colors: 'color, background-color, border-color, text-decoration-color, fill, stroke',
// var(--gp) is generated by the backgroundImage property when setting a gradient.
// It includes a list of all of the custom properties used for each color stop.
default: 'color, background-color, var(--gp), border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter',
colors: 'color, background-color, var(--gp), border-color, text-decoration-color, fill, stroke',
opacity: 'opacity',
shadow: 'box-shadow',
transform: 'transform, translate, scale, rotate',
Expand Down Expand Up @@ -802,7 +831,29 @@ export const style = createTheme({
borderBottomEndRadius: createRenamedProperty('borderEndEndRadius', radius),
forcedColorAdjust: ['auto', 'none'] as const,
colorScheme: ['light', 'dark', 'light dark'] as const,
backgroundImage: createArbitraryProperty<string>(),
backgroundImage: createArbitraryProperty<string | [LinearGradient]>((value, property) => {
if (typeof value === 'string') {
return {[property]: value};
} else if (Array.isArray(value) && value[0]?.type === 'linear-gradient') {
let values: CSSProperties = {
[property]: `linear-gradient(${value[0].angle}, ${value[0].stops.map(([, stop], i) => `var(--g${i}) ${stop}%`)})`
};

// Create a CSS var for each color stop so the gradient can be transitioned.
// These are registered via @property in the `linearGradient` macro.
let properties: string[] = [];
value[0].stops.forEach(([color], i) => {
properties.push(`--g${i}`);
values[`--g${i}`] = parseColor(color);
});

// This is used by transition-property so we automatically transition all of the color stops.
values['--gp'] = properties.join(', ');
return values;
} else {
throw new Error('Unexpected backgroundImage value: ' + JSON.stringify(value));
}
}),
// TODO: do we need separate x and y properties?
backgroundPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'] as const,
backgroundSize: ['auto', 'cover', 'contain'] as const,
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/s2/style/style-macro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

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

let defaultArbitraryProperty = <T extends Value>(value: T, property: string) => ({[property]: value} as CSSProperties);
export function createArbitraryProperty<T extends Value>(fn: (value: T, property: string) => CSSProperties = defaultArbitraryProperty): PropertyFunction<T> {
let defaultArbitraryProperty = <T>(value: T, property: string) => ({[property]: value} as CSSProperties);
export function createArbitraryProperty<T>(fn: (value: T, property: string) => CSSProperties = defaultArbitraryProperty): PropertyFunction<T> {
return (value, property) => {
let selector = Array.isArray(value) ? generateArbitraryValueSelector(value.map(v => String(v)).join('')) : generateArbitraryValueSelector(String(value));
let selector = Array.isArray(value) ? generateArbitraryValueSelector(value.map(v => JSON.stringify(v)).join('')) : generateArbitraryValueSelector(JSON.stringify(value));
return {default: [fn(value, property), selector]};
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/style/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type CSSProperties = CSS.Properties & {
[k: CustomProperty]: CSSValue
};

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

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

Expand Down
Loading