diff --git a/.changeset/seven-grapes-lay.md b/.changeset/seven-grapes-lay.md new file mode 100644 index 00000000000..e3e8d9fe240 --- /dev/null +++ b/.changeset/seven-grapes-lay.md @@ -0,0 +1,27 @@ +--- +'@clerk/ui': major +--- + +Changes provider icon rendering from `` to `` elements to support customizable icon fills via CSS variables. + +Provider icons for Apple, GitHub, OKX Wallet, and Vercel now use CSS `mask-image` technique with a customizable `--cl-icon-fill` CSS variable, allowing themes to control icon colors. Other provider icons (like Google) continue to render as full-color images using `background-image`. + +You can customize the icon fill color in your theme: + +```tsx +import { createTheme } from '@clerk/ui/themes'; + +const myTheme = createTheme({ + name: 'myTheme', + elements: { + providerIcon__apple: { + '--cl-icon-fill': '#000000', // Custom fill color + }, + providerIcon__github: { + '--cl-icon-fill': 'light-dark(#000000, #ffffff)', // Theme-aware fill + }, + }, +}); +``` + +This change enables better theme customization for monochrome provider icons while maintaining full-color support for providers that require it. diff --git a/.changeset/slimy-vans-listen.md b/.changeset/slimy-vans-listen.md new file mode 100644 index 00000000000..19f1f3d6815 --- /dev/null +++ b/.changeset/slimy-vans-listen.md @@ -0,0 +1,44 @@ +--- +'@clerk/ui': minor +--- + +Adds new `lightDark` theme. + +This theme uses the `light-dark()` CSS function to automatically adapt colors based on the user's system color scheme preference, eliminating the need to manually switch between light and dark themes. + +To enable it, within your project, you can do the following: + +```tsx +import { lightDark } from '@clerk/ui/themes'; +import { ClerkProvider } from '@clerk/nextjs'; + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ); +} +``` + +and within your CSS file, add the following to enable automatic light/dark mode switching: + +```css +:root { + color-scheme: light dark; +} +``` + +This will automatically switch between light and dark modes based on the user's system preference. Alternatively, you can use a class-based approach: + +```css +:root { + color-scheme: light; +} + +.dark { + color-scheme: dark; +} +``` + +**Note:** The `light-dark()` CSS function requires modern browser support (Chrome 123+, Firefox 120+, Safari 17.4+). For older browsers, consider using the `dark` theme with manual switching. diff --git a/.changeset/tangy-melons-rescue.md b/.changeset/tangy-melons-rescue.md new file mode 100644 index 00000000000..571298c7408 --- /dev/null +++ b/.changeset/tangy-melons-rescue.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Removes provider icon filter invert from elements for both `dark` and `shadcn` themes. diff --git a/packages/ui/src/elements/SocialButtons.tsx b/packages/ui/src/elements/SocialButtons.tsx index 2435f6f5111..569cd5780ec 100644 --- a/packages/ui/src/elements/SocialButtons.tsx +++ b/packages/ui/src/elements/SocialButtons.tsx @@ -12,9 +12,9 @@ import { Flex, Grid, Icon, - Image, localizationKeys, SimpleButton, + Span, Spinner, Text, useAppearance, @@ -30,6 +30,7 @@ import { distributeStrategiesIntoRows } from './utils'; const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1; const MAX_STRATEGIES_PER_ROW = 5; +const SUPPORTS_MASK_IMAGE = ['apple', 'github', 'okx_wallet', 'vercel']; export type SocialButtonsProps = React.PropsWithChildren<{ enableOAuthProviders: boolean; @@ -189,14 +190,31 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { }); const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( - {`Sign ({ width: theme.sizes.$4, height: 'auto', maxWidth: '100%' })} + aria-label={`Sign in with ${strategyToDisplayData[strategy].name}`} + sx={theme => ({ + display: 'inline-block', + width: theme.sizes.$4, + height: theme.sizes.$4, + maxWidth: '100%', + ...(SUPPORTS_MASK_IMAGE.includes(strategyToDisplayData[strategy].id) + ? { + '--cl-icon-fill': theme.colors.$colorForeground, + backgroundColor: 'var(--cl-icon-fill)', + maskImage: `url(${strategyToDisplayData[strategy].iconUrl})`, + maskSize: 'cover', + maskPosition: 'center', + maskRepeat: 'no-repeat', + } + : { + backgroundImage: `url(${strategyToDisplayData[strategy].iconUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }), + })} /> ) : ( = NonNullable; const primaryScale = colorOptionToThemedLightnessScale( - clerkCssVar('color-primary', '#2F3037'), + clerkCssVar('color-primary', lightDark('#2F3037', '#ffffff')), 'primary', ) as LightnessScale<'primary'>; const successScale = colorOptionToThemedLightnessScale( @@ -62,7 +63,7 @@ const neutralAlphaScale = colorOptionToThemedAlphaScale( 'neutralAlpha', ) as AlphaScale<'neutralAlpha'>; const primaryAlphaScale = colorOptionToThemedAlphaScale( - clerkCssVar('color-primary', '#2F3037'), + clerkCssVar('color-primary', lightDark('#2F3037', '#ffffff')), 'primaryAlpha', ) as AlphaScale<'primaryAlpha'>; const successAlphaScale = colorOptionToThemedAlphaScale( @@ -79,7 +80,7 @@ const borderAlphaScale = colorOptionToThemedAlphaScale( 'borderAlpha', ) as AlphaScale<'borderAlpha'>; -const colorForeground = clerkCssVar('color-foreground', '#212126'); +const colorForeground = clerkCssVar('color-foreground', lightDark('#212126', 'white')); const colorMutedForeground = clerkCssVar( 'color-muted-foreground', colorUtils.makeTransparent(colorForeground, 0.35) || '#747686', @@ -92,8 +93,8 @@ const colors = Object.freeze({ 'color-modal-backdrop', colorUtils.makeTransparent(defaultColorNeutral, 0.27) || neutralAlphaScale.neutralAlpha700, ), - colorBackground: clerkCssVar('color-background', 'white'), - colorInput: clerkCssVar('color-input', 'white'), + colorBackground: clerkCssVar('color-background', lightDark('#ffffff', '#212126')), + colorInput: clerkCssVar('color-input', lightDark('white', '#26262B')), colorForeground, colorMutedForeground, colorMuted: undefined, @@ -101,8 +102,8 @@ const colors = Object.freeze({ 'color-ring', colorUtils.makeTransparent(defaultColorNeutral, 0.85) || neutralAlphaScale.neutralAlpha200, ), - colorInputForeground: clerkCssVar('color-input-foreground', '#131316'), - colorPrimaryForeground: clerkCssVar('color-primary-foreground', 'white'), + colorInputForeground: clerkCssVar('color-input-foreground', lightDark('#131316', 'white')), + colorPrimaryForeground: clerkCssVar('color-primary-foreground', lightDark('white', 'black')), colorShimmer: clerkCssVar('color-shimmer', 'rgba(255, 255, 255, 0.36)'), transparent: 'transparent', white: 'white', diff --git a/packages/ui/src/themes/dark.ts b/packages/ui/src/themes/dark.ts index 384fd3af9b0..bd2f1c0e54c 100644 --- a/packages/ui/src/themes/dark.ts +++ b/packages/ui/src/themes/dark.ts @@ -12,10 +12,6 @@ export const dark = createTheme({ colorInput: '#26262B', }, elements: { - providerIcon__apple: { filter: 'invert(1)' }, - providerIcon__github: { filter: 'invert(1)' }, - providerIcon__okx_wallet: { filter: 'invert(1)' }, - providerIcon__vercel: { filter: 'invert(1)' }, activeDeviceIcon: { '--cl-chassis-bottom': '#d2d2d2', '--cl-chassis-back': '#e6e6e6', diff --git a/packages/ui/src/themes/shadcn.ts b/packages/ui/src/themes/shadcn.ts index 0fe0f148661..38e3412ecf5 100644 --- a/packages/ui/src/themes/shadcn.ts +++ b/packages/ui/src/themes/shadcn.ts @@ -32,9 +32,5 @@ export const shadcn = createTheme({ display: 'none', }, }, - providerIcon__apple: 'dark:invert', - providerIcon__github: 'dark:invert', - providerIcon__okx_wallet: 'dark:invert', - providerIcon__vercel: 'dark:invert', }, }); diff --git a/packages/ui/src/utils/__tests__/cssSupports.test.ts b/packages/ui/src/utils/__tests__/cssSupports.test.ts index 8c745cc15b2..d5a973d620d 100644 --- a/packages/ui/src/utils/__tests__/cssSupports.test.ts +++ b/packages/ui/src/utils/__tests__/cssSupports.test.ts @@ -1,29 +1,54 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { clearCache, cssSupports } from '../cssSupports'; +import { lightDark } from '../lightDark'; -// Mock CSS.supports -const originalCSSSupports = CSS.supports; +// Store original CSS if it exists +const originalCSS = globalThis.CSS; beforeAll(() => { - CSS.supports = vi.fn(feature => { - if (feature === 'color: hsl(from white h s l)') { - return true; - } - if (feature === 'color: color-mix(in srgb, white, black)') { + // Create mock CSS global for Node.js environment + globalThis.CSS = { + supports: vi.fn((feature: string) => { + if (feature === 'color: hsl(from white h s l)') { + return true; + } + if (feature === 'color: color-mix(in srgb, white, black)') { + return false; + } + if (feature === 'color: light-dark(white, black)') { + return true; + } return false; - } - return false; - }); + }), + } as unknown as typeof CSS; }); afterAll(() => { - CSS.supports = originalCSSSupports; + // Restore original CSS or remove if it didn't exist + if (originalCSS) { + globalThis.CSS = originalCSS; + } else { + // @ts-expect-error - cleaning up mock + delete globalThis.CSS; + } }); beforeEach(() => { clearCache(); - vi.mocked(CSS.supports).mockClear(); + // Reset mock to default behavior + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: hsl(from white h s l)') { + return true; + } + if (feature === 'color: color-mix(in srgb, white, black)') { + return false; + } + if (feature === 'color: light-dark(white, black)') { + return true; + } + return false; + }); }); describe('cssSupports', () => { @@ -39,10 +64,68 @@ describe('cssSupports', () => { expect(cssSupports.modernColor()).toBe(true); }); + test('lightDark should return true when supported', () => { + expect(cssSupports.lightDark()).toBe(true); + }); + test('caching works correctly', () => { + const initialCallCount = vi.mocked(globalThis.CSS.supports).mock.calls.length; cssSupports.relativeColorSyntax(); - expect(CSS.supports).toHaveBeenCalledTimes(1); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); cssSupports.relativeColorSyntax(); - expect(CSS.supports).toHaveBeenCalledTimes(1); // Should not call again due to caching + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); // Should not call again due to caching + }); + + test('lightDark caching works correctly', () => { + const initialCallCount = vi.mocked(globalThis.CSS.supports).mock.calls.length; + cssSupports.lightDark(); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); + cssSupports.lightDark(); + expect(globalThis.CSS.supports).toHaveBeenCalledTimes(initialCallCount + 1); // Should not call again due to caching + }); +}); + +describe('lightDark utility', () => { + test('returns light-dark() when both lightDark and modernColor are supported', () => { + // In this test setup: lightDark=true, relativeColorSyntax=true (so modernColor=true) + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('light-dark(#ffffff, #000000)'); + }); + + test('returns light value when lightDark is not supported', () => { + // Override mock to return false for lightDark + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: light-dark(white, black)') { + return false; + } + if (feature === 'color: hsl(from white h s l)') { + return true; + } + return false; + }); + clearCache(); + + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('#ffffff'); + }); + + test('returns light value when modernColor is not supported', () => { + // Override mock to return true for lightDark but false for modern color features + vi.mocked(globalThis.CSS.supports).mockImplementation((feature: string) => { + if (feature === 'color: light-dark(white, black)') { + return true; + } + // Both relativeColorSyntax and colorMix return false + return false; + }); + clearCache(); + + const result = lightDark('#ffffff', '#000000'); + expect(result).toBe('#ffffff'); + }); + + test('works with named colors', () => { + const result = lightDark('white', 'black'); + expect(result).toBe('light-dark(white, black)'); }); }); diff --git a/packages/ui/src/utils/cssSupports.ts b/packages/ui/src/utils/cssSupports.ts index cbc8fce494e..c4e96a9ea7f 100644 --- a/packages/ui/src/utils/cssSupports.ts +++ b/packages/ui/src/utils/cssSupports.ts @@ -1,11 +1,13 @@ const CSS_FEATURE_TESTS: Record = { relativeColorSyntax: 'color: hsl(from white h s l)', colorMix: 'color: color-mix(in srgb, white, black)', + lightDark: 'color: light-dark(white, black)', } as const; let SUPPORTS_RELATIVE_COLOR: boolean | undefined; let SUPPORTS_COLOR_MIX: boolean | undefined; let SUPPORTS_MODERN_COLOR: boolean | undefined; +let SUPPORTS_LIGHT_DARK: boolean | undefined; export const cssSupports = { relativeColorSyntax: () => { @@ -47,10 +49,26 @@ export const cssSupports = { return SUPPORTS_MODERN_COLOR; }, + /** + * Returns true if the light-dark() CSS function is supported + */ + lightDark: () => { + if (SUPPORTS_LIGHT_DARK !== undefined) { + return SUPPORTS_LIGHT_DARK; + } + try { + SUPPORTS_LIGHT_DARK = CSS.supports(CSS_FEATURE_TESTS.lightDark); + } catch { + SUPPORTS_LIGHT_DARK = false; + } + + return SUPPORTS_LIGHT_DARK; + }, }; export const clearCache = () => { SUPPORTS_RELATIVE_COLOR = undefined; SUPPORTS_COLOR_MIX = undefined; SUPPORTS_MODERN_COLOR = undefined; + SUPPORTS_LIGHT_DARK = undefined; }; diff --git a/packages/ui/src/utils/lightDark.ts b/packages/ui/src/utils/lightDark.ts new file mode 100644 index 00000000000..f811cdb40c8 --- /dev/null +++ b/packages/ui/src/utils/lightDark.ts @@ -0,0 +1,19 @@ +import { cssSupports } from './cssSupports'; + +/** + * Returns a light-dark() CSS function string when supported, + * otherwise returns the light value as a fallback. + * + * This ensures compatibility with the legacy color system which + * cannot parse light-dark() syntax. + * + * @param light - The color value for light mode + * @param dark - The color value for dark mode + * @returns CSS light-dark() function or the light value + */ +export function lightDark(light: string, dark: string): string { + if (cssSupports.lightDark() && cssSupports.modernColor()) { + return `light-dark(${light}, ${dark})`; + } + return light; +}