Skip to content

Commit 22be9f0

Browse files
feat(clerk-js): Update color logic utils to support CSS variable usage (#6187)
Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com>
1 parent 0e0cc1f commit 22be9f0

32 files changed

+3616
-472
lines changed

.changeset/chubby-parts-try.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Add CSS variable support to the `appearance.variables` object, enabling use of CSS custom properties. For example, you can now use `colorPrimary: 'var(--brand-color)'` to reference CSS variables defined in your stylesheets.
6+
7+
This feature includes automatic fallback support for browsers that don't support modern CSS color manipulation features.

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "111.8KB" },
8-
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "112.1KB" },
8+
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
1010
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
1111
{ "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" },
@@ -23,7 +23,7 @@
2323
{ "path": "./dist/waitlist*.js", "maxSize": "1.5KB" },
2424
{ "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
2525
{ "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" },
26-
{ "path": "./dist/checkout*.js", "maxSize": "8.34KB" },
26+
{ "path": "./dist/checkout*.js", "maxSize": "8.4KB" },
2727
{ "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" },
2828
{ "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" },
2929
{ "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" },

packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const NotificationCountBadgeSwitcherTrigger = () => {
113113
<NotificationCountBadge
114114
containerSx={t => ({
115115
position: 'absolute',
116-
top: `-${t.space.$2}`,
117-
right: `-${t.space.$2}`,
116+
top: `calc(${t.space.$2} * -1)`,
117+
right: `calc(${t.space.$2} * -1)`,
118118
})}
119119
notificationCount={notificationCount}
120120
/>

packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,40 @@ import { useCardState } from '@/ui/elements/contexts';
1313
import { Form } from '@/ui/elements/Form';
1414
import { FormButtons } from '@/ui/elements/FormButtons';
1515
import { FormContainer } from '@/ui/elements/FormContainer';
16+
import { resolveComputedCSSColor, resolveComputedCSSProperty } from '@/ui/utils/cssVariables';
1617
import { handleError } from '@/ui/utils/errorHandler';
17-
import { normalizeColorString } from '@/ui/utils/normalizeColorString';
1818

1919
import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts';
2020
import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables';
2121
import type { LocalizationKey } from '../../localization';
2222

23-
const useStripeAppearance = () => {
23+
const useStripeAppearance = (node: HTMLElement | null) => {
2424
const theme = useAppearance().parsedInternalTheme;
2525

2626
return useMemo(() => {
27+
if (!node) {
28+
return undefined;
29+
}
2730
const { colors, fontWeights, fontSizes, radii, space } = theme;
2831
return {
29-
colorPrimary: normalizeColorString(colors.$primary500),
30-
colorBackground: normalizeColorString(colors.$colorInputBackground),
31-
colorText: normalizeColorString(colors.$colorText),
32-
colorTextSecondary: normalizeColorString(colors.$colorTextSecondary),
33-
colorSuccess: normalizeColorString(colors.$success500),
34-
colorDanger: normalizeColorString(colors.$danger500),
35-
colorWarning: normalizeColorString(colors.$warning500),
36-
fontWeightNormal: fontWeights.$normal.toString(),
37-
fontWeightMedium: fontWeights.$medium.toString(),
38-
fontWeightBold: fontWeights.$bold.toString(),
39-
fontSizeXl: fontSizes.$xl,
40-
fontSizeLg: fontSizes.$lg,
41-
fontSizeSm: fontSizes.$md,
42-
fontSizeXs: fontSizes.$sm,
43-
borderRadius: radii.$md,
44-
spacingUnit: space.$1,
32+
colorPrimary: resolveComputedCSSColor(node, colors.$primary500, colors.$colorBackground),
33+
colorBackground: resolveComputedCSSColor(node, colors.$colorInputBackground, colors.$colorBackground),
34+
colorText: resolveComputedCSSColor(node, colors.$colorText, colors.$colorBackground),
35+
colorTextSecondary: resolveComputedCSSColor(node, colors.$colorTextSecondary, colors.$colorBackground),
36+
colorSuccess: resolveComputedCSSColor(node, colors.$success500, colors.$colorBackground),
37+
colorDanger: resolveComputedCSSColor(node, colors.$danger500, colors.$colorBackground),
38+
colorWarning: resolveComputedCSSColor(node, colors.$warning500, colors.$colorBackground),
39+
fontWeightNormal: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$normal.toString()),
40+
fontWeightMedium: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$medium.toString()),
41+
fontWeightBold: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$bold.toString()),
42+
fontSizeXl: resolveComputedCSSProperty(node, 'font-size', fontSizes.$xl),
43+
fontSizeLg: resolveComputedCSSProperty(node, 'font-size', fontSizes.$lg),
44+
fontSizeSm: resolveComputedCSSProperty(node, 'font-size', fontSizes.$md),
45+
fontSizeXs: resolveComputedCSSProperty(node, 'font-size', fontSizes.$sm),
46+
borderRadius: resolveComputedCSSProperty(node, 'border-radius', radii.$lg),
47+
spacingUnit: resolveComputedCSSProperty(node, 'padding', space.$1),
4548
};
46-
}, [theme]);
49+
}, [theme, node]);
4750
};
4851

4952
type AddPaymentSourceProps = {
@@ -66,11 +69,12 @@ const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHo
6669

6770
const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren<AddPaymentSourceProps>) => {
6871
const subscriberType = useSubscriberTypeContext();
72+
const stripeAppearanceNode = useRef<HTMLDivElement | null>(null);
6973
const { t } = useLocalizations();
7074
const [headerTitle, setHeaderTitle] = useState<LocalizationKey | undefined>(undefined);
7175
const [headerSubtitle, setHeaderSubtitle] = useState<LocalizationKey | undefined>(undefined);
7276
const [submitLabel, setSubmitLabel] = useState<LocalizationKey | undefined>(undefined);
73-
const stripeAppearance = useStripeAppearance();
77+
const stripeAppearance = useStripeAppearance(stripeAppearanceNode.current);
7478

7579
return (
7680
<AddPaymentSourceContext.Provider
@@ -87,6 +91,10 @@ const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren
8791
},
8892
}}
8993
>
94+
<div
95+
ref={stripeAppearanceNode}
96+
style={{ display: 'none' }}
97+
/>
9098
<PaymentElementProvider
9199
checkout={checkout}
92100
for={subscriberType}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
// Mock cssSupports
4+
vi.mock('../../utils/cssSupports', () => ({
5+
cssSupports: {
6+
modernColor: vi.fn(),
7+
},
8+
}));
9+
10+
import { cssSupports } from '@/ui/utils/cssSupports';
11+
12+
import { removeInvalidValues } from '../parseVariables';
13+
14+
const mockModernColorSupport = vi.mocked(cssSupports.modernColor);
15+
16+
describe('removeInvalidValues', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
mockModernColorSupport.mockReturnValue(false);
20+
});
21+
22+
it('should return the variables as-is if modern color support is present', () => {
23+
mockModernColorSupport.mockReturnValue(true);
24+
const variables = {
25+
colorPrimary: 'var(--color-primary)',
26+
};
27+
28+
const result = removeInvalidValues(variables);
29+
expect(result).toEqual({ colorPrimary: 'var(--color-primary)' });
30+
});
31+
32+
it('should remove invalid string values if modern color support is not present', () => {
33+
mockModernColorSupport.mockReturnValue(false);
34+
const variables = {
35+
colorPrimary: 'var(--color-primary)',
36+
colorDanger: '#ff0000',
37+
};
38+
39+
const result = removeInvalidValues(variables);
40+
expect(result).toEqual({ colorDanger: '#ff0000' });
41+
});
42+
43+
it('should remove invalid object values if modern color support is not present', () => {
44+
mockModernColorSupport.mockReturnValue(false);
45+
const variables = {
46+
colorPrimary: {
47+
400: 'var(--color-primary-500)',
48+
500: '#fff',
49+
},
50+
colorDanger: {
51+
500: '#ff0000',
52+
},
53+
};
54+
55+
const result = removeInvalidValues(variables);
56+
expect(result).toEqual({ colorDanger: { 500: '#ff0000' } });
57+
});
58+
});

packages/clerk-js/src/ui/customizables/parseVariables.ts

Lines changed: 75 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,89 @@ import type { Theme } from '@clerk/types';
22

33
import { spaceScaleKeys } from '../foundations/sizes';
44
import type { fontSizes, fontWeights } from '../foundations/typography';
5-
import { colorOptionToHslaAlphaScale, colorOptionToHslaLightnessScale } from '../utils/colorOptionToHslaScale';
65
import { colors } from '../utils/colors';
6+
import { colorOptionToThemedAlphaScale, colorOptionToThemedLightnessScale } from '../utils/colors/scales';
7+
import { cssSupports } from '../utils/cssSupports';
78
import { fromEntries } from '../utils/fromEntries';
89
import { removeUndefinedProps } from '../utils/removeUndefinedProps';
910

1011
export const createColorScales = (theme: Theme) => {
11-
const variables = theme.variables || {};
12+
const variables = removeInvalidValues(theme.variables || {});
1213

13-
const primaryScale = colorOptionToHslaLightnessScale(variables.colorPrimary, 'primary');
14-
const primaryAlphaScale = colorOptionToHslaAlphaScale(primaryScale?.primary500, 'primaryAlpha');
15-
const dangerScale = colorOptionToHslaLightnessScale(variables.colorDanger, 'danger');
16-
const dangerAlphaScale = colorOptionToHslaAlphaScale(dangerScale?.danger500, 'dangerAlpha');
17-
const successScale = colorOptionToHslaLightnessScale(variables.colorSuccess, 'success');
18-
const successAlphaScale = colorOptionToHslaAlphaScale(successScale?.success500, 'successAlpha');
19-
const warningScale = colorOptionToHslaLightnessScale(variables.colorWarning, 'warning');
20-
const warningAlphaScale = colorOptionToHslaAlphaScale(warningScale?.warning500, 'warningAlpha');
14+
const dangerScale = colorOptionToThemedLightnessScale(variables.colorDanger, 'danger');
15+
const primaryScale = colorOptionToThemedLightnessScale(variables.colorPrimary, 'primary');
16+
const successScale = colorOptionToThemedLightnessScale(variables.colorSuccess, 'success');
17+
const warningScale = colorOptionToThemedLightnessScale(variables.colorWarning, 'warning');
18+
19+
const dangerAlphaScale = colorOptionToThemedAlphaScale(dangerScale?.danger500, 'dangerAlpha');
20+
const neutralAlphaScale = colorOptionToThemedAlphaScale(variables.colorNeutral, 'neutralAlpha');
21+
const primaryAlphaScale = colorOptionToThemedAlphaScale(primaryScale?.primary500, 'primaryAlpha');
22+
const successAlphaScale = colorOptionToThemedAlphaScale(successScale?.success500, 'successAlpha');
23+
const warningAlphaScale = colorOptionToThemedAlphaScale(warningScale?.warning500, 'warningAlpha');
2124

2225
return removeUndefinedProps({
23-
...primaryScale,
24-
...primaryAlphaScale,
2526
...dangerScale,
26-
...dangerAlphaScale,
27+
...primaryScale,
2728
...successScale,
28-
...successAlphaScale,
2929
...warningScale,
30+
...dangerAlphaScale,
31+
...neutralAlphaScale,
32+
...primaryAlphaScale,
33+
...successAlphaScale,
3034
...warningAlphaScale,
31-
...colorOptionToHslaAlphaScale(variables.colorNeutral, 'neutralAlpha'),
3235
primaryHover: colors.adjustForLightness(primaryScale?.primary500),
33-
colorTextOnPrimaryBackground: toHSLA(variables.colorTextOnPrimaryBackground),
34-
colorText: toHSLA(variables.colorText),
35-
colorTextSecondary: toHSLA(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 0.35),
36-
colorInputText: toHSLA(variables.colorInputText),
37-
colorBackground: toHSLA(variables.colorBackground),
38-
colorInputBackground: toHSLA(variables.colorInputBackground),
39-
colorShimmer: toHSLA(variables.colorShimmer),
36+
colorTextOnPrimaryBackground: colors.toHslaString(variables.colorTextOnPrimaryBackground),
37+
colorText: colors.toHslaString(variables.colorText),
38+
colorTextSecondary:
39+
colors.toHslaString(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 0.35),
40+
colorInputText: colors.toHslaString(variables.colorInputText),
41+
colorBackground: colors.toHslaString(variables.colorBackground),
42+
colorInputBackground: colors.toHslaString(variables.colorInputBackground),
43+
colorShimmer: colors.toHslaString(variables.colorShimmer),
4044
});
4145
};
4246

43-
export const toHSLA = (str: string | undefined) => {
44-
return str ? colors.toHslaString(str) : undefined;
47+
export const removeInvalidValues = (variables: NonNullable<Theme['variables']>): NonNullable<Theme['variables']> => {
48+
// Check for modern color support. If present, we can simply return the variables as-is since we support everything
49+
// CSS supports.
50+
if (cssSupports.modernColor()) {
51+
return variables;
52+
}
53+
54+
// If not, we need to remove any values that are specified with CSS variables, as our color scale generation only
55+
// supports CSS variables using modern CSS functionality.
56+
const validVariables: Theme['variables'] = Object.fromEntries(
57+
Object.entries(variables).filter(([key, value]) => {
58+
if (typeof value === 'string') {
59+
const isValid = !value.startsWith('var(');
60+
if (!isValid) {
61+
console.warn(
62+
`Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`,
63+
);
64+
}
65+
return isValid;
66+
}
67+
68+
if (typeof value === 'object') {
69+
return Object.entries(value).every(([key, value]) => {
70+
if (typeof value !== 'string') return true;
71+
72+
const isValid = !value.startsWith('var(');
73+
if (!isValid) {
74+
console.warn(
75+
`Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`,
76+
);
77+
}
78+
79+
return isValid;
80+
});
81+
}
82+
83+
return false;
84+
}),
85+
);
86+
87+
return validVariables;
4588
};
4689

4790
export const createRadiiUnits = (theme: Theme) => {
@@ -51,12 +94,11 @@ export const createRadiiUnits = (theme: Theme) => {
5194
}
5295

5396
const md = borderRadius === 'none' ? '0' : borderRadius;
54-
const { numericValue, unit = 'rem' } = splitCssUnit(md);
5597
return {
56-
sm: (numericValue * 0.66).toString() + unit,
98+
sm: `calc(${md} * 0.66)`,
5799
md,
58-
lg: (numericValue * 1.33).toString() + unit,
59-
xl: (numericValue * 2).toString() + unit,
100+
lg: `calc(${md} * 1.33)`,
101+
xl: `calc(${md} * 2)`,
60102
};
61103
};
62104

@@ -65,12 +107,11 @@ export const createSpaceScale = (theme: Theme) => {
65107
if (spacingUnit === undefined) {
66108
return;
67109
}
68-
const { numericValue, unit } = splitCssUnit(spacingUnit);
69110
return fromEntries(
70111
spaceScaleKeys.map(k => {
71112
const num = Number.parseFloat(k.replace('x', '.'));
72113
const percentage = (num / 0.5) * 0.125;
73-
return [k, `${numericValue * percentage}${unit}`];
114+
return [k, `calc(${spacingUnit} * ${percentage})`];
74115
}),
75116
);
76117
};
@@ -83,13 +124,12 @@ export const createFontSizeScale = (theme: Theme): Record<keyof typeof fontSizes
83124
if (fontSize === undefined) {
84125
return;
85126
}
86-
const { numericValue, unit = 'rem' } = splitCssUnit(fontSize);
87127
return {
88-
xs: (numericValue * 0.8).toString() + unit,
89-
sm: (numericValue * 0.9).toString() + unit,
128+
xs: `calc(${fontSize} * 0.8)`,
129+
sm: `calc(${fontSize} * 0.9)`,
90130
md: fontSize,
91-
lg: (numericValue * 1.3).toString() + unit,
92-
xl: (numericValue * 1.85).toString() + unit,
131+
lg: `calc(${fontSize} * 1.3)`,
132+
xl: `calc(${fontSize} * 1.85)`,
93133
};
94134
};
95135

@@ -102,9 +142,3 @@ export const createFonts = (theme: Theme) => {
102142
const { fontFamily, fontFamilyButtons } = theme.variables || {};
103143
return removeUndefinedProps({ main: fontFamily, buttons: fontFamilyButtons });
104144
};
105-
106-
const splitCssUnit = (str: string) => {
107-
const numericValue = Number.parseFloat(str);
108-
const unit = str.replace(numericValue.toString(), '') || undefined;
109-
return { numericValue, unit };
110-
};

packages/clerk-js/src/ui/elements/Card/CardFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>((pro
4949
elementDescriptor={descriptors.footer}
5050
sx={[
5151
t => ({
52-
marginTop: `-${t.space.$2}`,
52+
marginTop: `calc(${t.space.$2} * -1)`,
5353
paddingTop: t.space.$2,
5454
background: common.mergedColorsBackground(
5555
colors.setAlpha(t.colors.$colorBackground, 1),

packages/clerk-js/src/ui/elements/CodeControl.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
336336
hasError={feedbackType === 'error'}
337337
elementDescriptor={descriptors.otpCodeFieldInputs}
338338
gap={2}
339-
sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })}
339+
sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `calc(${t.space.$1} * -1)`, ...centerSx })}
340340
role='group'
341341
aria-label='Verification code input'
342342
>

packages/clerk-js/src/ui/elements/Navbar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ const NavbarContainer = (
159159
t.colors.$neutralAlpha50,
160160
),
161161
padding: `${t.space.$6} ${t.space.$5} ${t.space.$4} ${t.space.$3}`,
162-
marginRight: `-${t.space.$2}`,
162+
marginRight: `calc(${t.space.$2} * -1)`,
163163
color: t.colors.$colorText,
164164
justifyContent: 'space-between',
165165
})}
@@ -332,7 +332,7 @@ export const NavbarMenuButtonRow = ({ navbarTitleLocalizationKey, ...props }: Na
332332
t.colors.$neutralAlpha50,
333333
),
334334
padding: `${t.space.$2} ${t.space.$3} ${t.space.$4} ${t.space.$3}`,
335-
marginBottom: `-${t.space.$2}`,
335+
marginBottom: `calc(${t.space.$2} * -1)`,
336336
[mqu.md]: {
337337
display: 'flex',
338338
},

packages/clerk-js/src/ui/elements/PopoverCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const PopoverCardFooter = (props: PropsOfComponent<typeof Flex>) => {
8585
colors.setAlpha(t.colors.$colorBackground, 1),
8686
t.colors.$neutralAlpha50,
8787
),
88-
marginTop: `-${t.space.$2}`,
88+
marginTop: `calc(${t.space.$2} * -1)`,
8989
paddingTop: t.space.$2,
9090
'&:empty': {
9191
padding: 0,

0 commit comments

Comments
 (0)