Skip to content

Make Clerk's sign-in look native, in light and dark, with CSS variables

Point Clerk's appearance variables at your design's CSS custom properties so the prebuilt components follow your theme and your dark mode, with no copied hex values.

Clerk3 min readships at docs/solutions/clerk/styling-clerk-with-css-variables.md

Tags: clerk · nextjs · theming · dark-mode · design-tokens

Clerk's <SignIn /> drops into a page in one line and looks like Clerk. The usual fix is to copy your brand colours into the appearance prop:

<ClerkProvider appearance={{ variables: { colorPrimary: "#4f46e5" } }}>

That works until you add dark mode, change the palette, or let a customer pick a theme. Every copied value is a second source of truth, and it only has one mode.

Pass the variable, not the value

Clerk's appearance variables accept CSS custom properties. Hand it var(--primary) and the browser resolves it at paint time, so Clerk follows whatever your stylesheet says right now, including the dark block:

// src/lib/auth/appearance.ts
export const clerkAppearance = {
  variables: {
    colorPrimary: "var(--primary)",
    colorPrimaryForeground: "var(--primary-foreground)",
    colorDanger: "var(--destructive)",
    colorForeground: "var(--foreground)",
    colorNeutral: "var(--foreground)",
    colorMutedForeground: "var(--muted-foreground)",
    colorBackground: "var(--card)",
    colorInput: "var(--background)",
    colorInputForeground: "var(--foreground)",
    colorRing: "var(--ring)",
    fontFamily: "var(--font-body, inherit)",
    borderRadius: "var(--radius-md, 0.375rem)",
  },
};

If your design system follows shadcn's names, every one of those already exists in both modes. Toggle the theme and the sign-in card flips with the page, because nothing in it was ever a literal.

The border trap

The obvious move is colorBorder: "var(--border)". Do not. Clerk draws its borders from colorBorder at a low opacity, so an already light hairline colour turns almost invisible, most visibly in dark mode.

Leave colorBorder out. Clerk then derives borders from colorNeutral, and if that is your text colour, a few percent of it lands close to your design's hairline in both modes. Check it against a real input on your page before you fight it.

Browser support

Clerk mixes hover and border shades from these values with color-mix() and relative colour syntax. Clerk's docs list Chrome 111, Firefox 113 and Safari 16.2 as the floor for that. If you must support older browsers, you are back to literal values, one set per mode.

Start from Clerk's plain theme

Clerk's default look adds its own touches on top of your variables: a gradient sheen on the primary button and soft shadows. Next to your own flat buttons they read as someone else's component. Clerk ships a plain base for exactly this:

export const clerkAppearance = {
  theme: "simple",
  variables: { /* as above */ },
};

With simple, what you see is your variables and nothing else.

Let your card be the card

Clerk renders its own card with its own shadow and radius. Inside your page that is a card in a card. Turn it off and wrap Clerk in your own component:

options: { elevation: "flush", logoPlacement: "none" },
// Your page already says "By continuing you agree to the Terms". Leave
// termsPageUrl and privacyPageUrl unset, or Clerk prints the links again.
elements: {
  rootBox: { width: "100%" },
  cardBox: { width: "100%", maxWidth: "100%" },
},
<Card>
  <SignIn fallbackRedirectUrl="/dashboard" />
</Card>

options is Clerk Core 3's name for what was layout.

Style objects, not class names

elements accepts class names, and a Tailwind class there looks like it should work. It often does not: Clerk's styles are not in a cascade layer, and unlayered CSS beats anything in Tailwind's layers regardless of specificity. A style object sets the property directly and wins:

elements: {
  formFieldInput: { minHeight: "2.5rem" },        // match your own inputs
  formButtonPrimary: { minHeight: "2.5rem", boxShadow: "none" },
},

Settings pages without Clerk's chrome

<UserProfile /> has its own sidebar. If your app already has settings tabs, render one Clerk page per tab and hide the sidebar:

<UserProfile routing="hash" appearance={{
  elements: { navbar: { display: "none" }, navbarMobileMenuRow: { display: "none" } },
}}>
  <UserProfile.Page label="security" />
  <UserProfile.Page label="account" />
</UserProfile>

Listing a built-in page first makes it the one that opens. routing="hash" because a plain /settings/security route has no catch-all segment for Clerk's path routing.

Check it

  • Toggle light and dark on /sign-in: the card, inputs, buttons and links all change, with no reload.
  • Swap your design's --primary: the Continue button follows.
  • Grep src/lib/auth/appearance.ts for #: nothing.
  • Tab through the form: the focus ring is your --ring.