diff --git a/.changeset/empty-oigyvq.md b/.changeset/empty-oigyvq.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/empty-oigyvq.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/AGENTS.md b/AGENTS.md index 786c572a61f..3d5f87ecc4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,3 +6,7 @@ Clerk's JavaScript SDK and library monorepo. - Non-major releases in `packages/clerk-js` and `packages/ui` are pushed out to consuming applications without requiring explicit package updates. Extra care must be put into any changes to these packages. - The API exposed from the core Clerk class in `packages/clerk-js/src/core/clerk.ts` is a contract that is depended on by internal and external consumers. Changes to this API must be done in a major version to avoid breakage. + +## References + +- For questions about theming, appearance customization, or the styled system, see `references/theming-architecture.md`. diff --git a/references/theming-architecture.md b/references/theming-architecture.md new file mode 100644 index 00000000000..b4170d91ae1 --- /dev/null +++ b/references/theming-architecture.md @@ -0,0 +1,436 @@ +# Clerk UI Theming Architecture + +## Overview + +Clerk's theming system lets users customize every visual aspect of Clerk components — from broad design tokens (colors, fonts, spacing) down to individual element styles. It's built on a layered cascade model, processed through a parsing pipeline, and rendered via Emotion CSS-in-JS. + +--- + +## How Users Configure Themes + +Users pass an `appearance` prop to `` (global) or directly to individual components like ``. + +```tsx +import { ClerkProvider } from '@clerk/nextjs'; +import { dark } from '@clerk/themes'; + +; +``` + +### The `Theme` Object + +Every `appearance` prop is a `Theme` with these keys: + +| Key | Purpose | +| ----------- | ------------------------------------------------------------------ | +| `theme` | A prebuilt theme (or array of themes) to use as the starting point | +| `variables` | High-level design tokens (colors, fonts, spacing, radii, shadows) | +| `elements` | Per-element CSS overrides using element descriptor keys | +| `options` | Layout options (logo placement, social button position, etc.) | +| `captcha` | CAPTCHA widget appearance | + +### The `Appearance` Object + +`Appearance` extends `Theme` with per-component override keys: + +``` +Appearance = Theme & { + signIn?: Theme + signUp?: Theme + userButton?: Theme + userProfile?: Theme + organizationSwitcher?: Theme + organizationProfile?: Theme + // ... every Clerk component has a key +} +``` + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ USER CONFIGURATION │ +│ │ +│ ClerkProvider │ +│ appearance={ appearance={ │ +│ theme: dark, ───┐ variables: {...}, │ +│ variables: {...}, │ elements: {...}, │ +│ elements: {...}, │ } │ +│ signIn: {...}, │ │ │ +│ } │ │ │ +│ │ │ │ │ +└─────────┼───────────────────┼──────────────┼────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ APPEARANCE PROVIDER │ +│ │ +│ AppearanceProvider receives three inputs: │ +│ • globalAppearance (from ClerkProvider) │ +│ • appearanceKey ("signIn", "userButton", etc.) │ +│ • componentAppearance (from the component's own prop) │ +│ │ +│ Calls parseAppearance() to resolve everything ─────────────────┐ │ +│ │ │ +└─────────────────────────────────────────────────────────────────┼───┘ + │ + ┌───────────────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PARSE APPEARANCE PIPELINE │ +│ packages/ui/src/customizables/parseAppearance.ts │ +│ │ +│ Step 1: BUILD THE CASCADE │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Ordered list (lowest → highest priority): │ │ +│ │ │ │ +│ │ ① baseTheme (clerkTheme) ← default Clerk styling │ │ +│ │ ② globalAppearance ← ClerkProvider appearance │ │ +│ │ ③ globalAppearance[key] ← e.g. appearance.signIn │ │ +│ │ ④ componentAppearance ← │ │ +│ │ │ │ +│ │ Each layer's `theme` property is recursively expanded │ │ +│ │ (depth-first). E.g. theme: [dark, custom] expands to │ │ +│ │ [dark, custom, parentTheme]. │ │ +│ │ │ │ +│ │ If any layer sets simpleStyles: true, the baseTheme │ │ +│ │ is excluded (used by neobrutalism theme). │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Step 2a: │ │ Step 2b: │ │ Step 2c: │ │ +│ │ PARSE │ │ PARSE │ │ PARSE │ │ +│ │ VARIABLES │ │ ELEMENTS │ │ OPTIONS │ │ +│ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ parsedInternal- parsedElements parsedOptions │ +│ Theme (Elements[]) (layout config) │ +│ │ +│ Output: { parsedInternalTheme, parsedElements, parsedOptions } │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Variable Resolution: From Tokens to Styles + +When a user sets `variables.colorPrimary: '#6C47FF'`, here's what happens: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ VARIABLE PARSING PIPELINE │ +│ packages/ui/src/customizables/parseVariables.ts │ +│ │ +│ variables.colorPrimary: '#6C47FF' │ +│ │ │ +│ ▼ │ +│ createColorScales() │ +│ ├─ Generates 15-shade lightness scale (25–950) │ +│ │ using color-mix() or HSL manipulation │ +│ ├─ Generates 15-shade alpha scale │ +│ └─ Output: │ +│ $primaryColor25: 'color-mix(in srgb, #6C47FF, white 92%)' │ +│ $primaryColor50: 'color-mix(in srgb, #6C47FF, white 85%)' │ +│ $primaryColor100: 'color-mix(in srgb, #6C47FF, white 73%)' │ +│ ... │ +│ $primaryColor500: '#6C47FF' ← the base shade │ +│ ... │ +│ $primaryColor950: 'color-mix(in srgb, #6C47FF, black 70%)' │ +│ │ +│ Similarly for other variable categories: │ +│ createRadiiUnits() → $radius1, $radius2, ... │ +│ createSpaceScale() → $space1, $space2, ... │ +│ createFontSizeScale() → $fontSizeXs, $fontSizeSm, ... │ +│ createFontWeightScale()→ $fontWeightNormal, $fontWeightBold, ... │ +│ createFonts() → $fontFamily, $fontFamilyButtons │ +│ createShadowsUnits() → $cardContentShadow, $input, ... │ +│ │ +│ All scales deep-merged onto defaultInternalTheme │ +│ │ │ +│ ▼ │ +│ parsedInternalTheme (InternalTheme) │ +│ ┌──────────────────────────────────────┐ │ +│ │ { │ │ +│ │ colors: { │ │ +│ │ $primaryColor500: '#6C47FF', │ │ +│ │ $primaryColor600: '...', │ │ +│ │ $dangerColor500: '#EF4444', │ │ +│ │ $neutralAlpha100: '...', │ │ +│ │ $colorBackground: '...', │ │ +│ │ ... │ │ +│ │ }, │ │ +│ │ radii: { $radius1: '...', ... }, │ │ +│ │ space: { $space1: '...', ... }, │ │ +│ │ fonts: { $fontFamily: '...' }, │ │ +│ │ fontSizes: { ... }, │ │ +│ │ fontWeights: { ... }, │ │ +│ │ shadows: { ... }, │ │ +│ │ } │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Default Values and CSS Variables + +The foundation layer (`packages/ui/src/foundations/`) defines every default token using `--clerk-*` CSS variables with hardcoded fallbacks: + +``` +colorBackground: var(--clerk-color-background, #ffffff) +colorPrimary: var(--clerk-color-primary, #2F3037) +fontSize: var(--clerk-font-size, 0.8125rem) +borderRadius: var(--clerk-border-radius, 0.375rem) +spacing: calc(var(--clerk-spacing, 1rem) * N) +``` + +This creates **two override paths**: + +1. **CSS-only**: Set `--clerk-color-primary: hotpink` on any parent element — works without JS +2. **JS (appearance prop)**: Pass `variables.colorPrimary` — the parsed values override the CSS variable fallbacks via Emotion's inline styles + +--- + +## How Styles Reach Components + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PROVIDER TREE (per component) │ +│ │ +│ StyleCacheProvider ← Emotion cache (key: 'cl-internal') │ +│ │ Optional @layer wrapping │ +│ └─ AppearanceProvider ← Holds parsed theme + elements │ +│ │ │ +│ └─ InternalThemeProvider ← Emotion ThemeProvider │ +│ │ (theme = parsedInternalTheme) │ +│ │ │ +│ └─ Component Tree │ +│ │ │ +│ ├─