Skip to main content

Customizing <UserButton /> for the Clerk Dashboard

Category
Engineering
Published

How the Clerk Dashboard replaced a hand-built org switcher with a few small CSS overrides, and why plain CSS makes Clerk components easier to customize with a coding agent.

Customizing <UserButton /> for the Clerk Dashboard

More styling work now happens in a coding agent. Point it at a component, tell it what you want, and it edits the CSS. That works well when the styles are easy to find and change, and poorly when they're buried in a library-specific API.

We saw this in the Clerk Dashboard. Our org switcher, built on UserButton, needed to match the rest of the product, so we'd rebuilt the surrounding UI by hand. With the newest version of our UserButton component, we were able to delete most of that code. What's left is a handful of small CSS overrides, which are an easy tool for an agent to reach for when customizing components to match an existing design system.

It works because this new version of Clerk's components is styled with a plain CSS file you import. Our previous components were styled with CSS too, but through a CSS-in-JS solution. You customized them with the appearance prop or by overriding stable class names, and those overrides ran into specificity problems because of how their dynamic stylesheet was inserted into the page. Now the styles are a stylesheet like any other, with classes, attributes, and layers, which agents already know well.

Here's what changed, and why each piece helps when an agent is doing the styling.

  • Restyle in place. A few rules against stable class names replace the wrapper we used to build.
  • CSS layers you control. You choose where the stylesheet is imported, which sets where it sits in the cascade. Your styles win without the specificity fights.
  • State attributes for enter and exit. Animation state is exposed as data attributes, so you can override the motion in plain CSS.
  • Plain CSS, wherever you want it. Use one stylesheet, several, or CSS next to your component code. Wherever you can write CSS, you can customize the components.

How it used to work

Customizing the previous components usually meant using the appearance prop to target internal element names and pass in class strings or style objects.

<ClerkUserButton
  appearance={{
    elements: {
      rootBox: 'flex w-full',
      userButtonPopoverRootBox: 'w-full',
      userButtonPopoverMain: 'z-0 rounded-top-none',
      popoverBox: 'w-full max-w-none z-0 rounded-none shadow-none border-none',
    },
  }}
/>

When we ran up against the limits of that approach, we built our own trigger and popover and embedded Clerk's UI inside it. Our organization switcher became hundreds of lines of code, with a custom popover, a store tracking whether it was open, manual invitation counting, query invalidation callbacks, and a hydration workaround. All of it existed so the Clerk piece would sit comfortably inside our header.

What changed

The Dashboard header now renders UserButton 2.0 with a handful of props, and all the styling to integrate it with our design system lives in a stylesheet.

<UserButton
  triggerVariant="condensed"
  organizationProfileUrl="/settings"
  afterSelectOrganizationUrl="/apps"
/>

Restyle in place, instead of rebuilding

In the Dashboard we used to wrap the previous components in our own trigger and popover. With UserButton 2.0 we can achieve the same result with only a few rules against stable class names and state attributes, so there was nothing left to rebuild.

This is the actual code for our trigger (we write it with Tailwind utilities like h-8 rounded border-black/5 shadow-black/2, so this is the plain CSS they compile to, with our theme values filled in).

/* scoped to our app header */
[data-app-layout-header] .cl-user-button-trigger {
  position: relative;
  isolation: isolate;
  height: 2rem;
  padding-block: 0;
  border: 1px solid transparent;
  border-radius: 0.4375rem; /* our --radius, 7px */
  background-color: transparent;
  background-clip: padding-box;
  /* Tailwind's `transition` utility lists more properties than this (opacity,
     transform, filter and others). These are the ones that change here. */
  transition-property: background-color, border-color, box-shadow;
  transition-duration: 150ms;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);

  /* A dark-mode inner highlight, hidden until the button is hovered or open */
  &::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    opacity: 0;
    box-shadow:
      inset 0 1px 0 rgb(255 255 255 / 0.02),
      inset 0 1px 0 rgb(255 255 255 / 0.02),
      inset 0 0 0 1px rgb(255 255 255 / 0.02);
    transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
  }

  /* Pressed, or the menu is open */
  &:active,
  &[data-open] {
    background-color: white;
    border-color: rgb(0 0 0 / 0.05);
    box-shadow:
      0 1px 0 -1px rgb(0 0 0 / 0.02),
      0 1px 2px -0.5px rgb(0 0 0 / 0.02),
      0 2px 2px -1px rgb(0 0 0 / 0.02),
      0 3px 6px -4px rgb(0 0 0 / 0.02);

    &:where(.dark, .dark *) {
      background-color: #232328;

      &::before {
        opacity: 1;
      }
    }
  }

  /* The same look on hover, only where the device can hover, so it
     doesn't stick on touch screens */
  @media (hover: hover) {
    &:hover {
      background-color: white;
      border-color: rgb(0 0 0 / 0.05);
      box-shadow:
        0 1px 0 -1px rgb(0 0 0 / 0.02),
        0 1px 2px -0.5px rgb(0 0 0 / 0.02),
        0 2px 2px -1px rgb(0 0 0 / 0.02),
        0 3px 6px -4px rgb(0 0 0 / 0.02);

      &:where(.dark, .dark *) {
        background-color: #232328;

        &::before {
          opacity: 1;
        }
      }
    }
  }
}

/* plus the label, plan badge and popover animation rules: about 120 lines in total */

The components take their colors from a small set of semantic tokens, like --cl-color-surface, --cl-color-foreground, --cl-color-border and --cl-color-brand. Fitting them into an existing design system mostly means setting those tokens once, at the root, to variables you already have. The surface token is the color that menus, popovers and cards sit on. Text and hover tints are derived from it and a neutral color, so they follow along.

In the Dashboard, that comes down to one line for the switcher:

:root {
  --cl-color-surface: var(--bg-main);
}

Brand, positive, negative and warning keep their defaults unless you set them too, and overriding one retints its companion colors. The tokens use light-dark(), so a single value replaces both modes. Dark mode follows when your own variable changes with your theme, which ours does.

CSS layers you control

The previous components supported CSS layers too, through a layer name on the appearance prop, but that option was limiting. Now the stylesheet is a normal CSS file you import where you want, into a layer you name, and you define the layer order yourself.

@layer theme, base, clerk, clerkOverrides, components, utilities;
@import 'tailwindcss';
@import '@clerk/mosaic/styles.css' layer(clerk);
@import './styles/clerk.css' layer(clerkOverrides);

Layer order beats selector specificity, so anything in clerkOverrides wins over the component's styles however specific its selectors are. No !important, no doubled-up selectors.

Because the stylesheet is a regular CSS file in your own app, it can be cached with the rest of your CSS. Even though the initial CSS download can be larger, we believe this approach results in better performance compared to our previous components, which shipped CSS inside our JavaScript bundles. Furthermore, since we use StyleX for our new components, we anticipate that our CSS will grow at a slower rate compared to previous components. It also gives you direct control over how and when it loads.

Animations through data-starting-style and data-ending-style

The popover gets data-starting-style as it mounts and data-ending-style as it leaves, so the animation is ordinary CSS. Here's a version of what we use in the Dashboard. It's subtle and close to the default, but it matches our other components.

.cl-user-button-popover {
  transform: none; /* clear the default scale, see below */
  scale: 1;
  opacity: 1;
  transition:
    scale var(--cl-duration-base) var(--cl-ease-enter),
    opacity var(--cl-duration-base) var(--cl-ease-enter);

  &[data-starting-style],
  &[data-ending-style] {
    scale: 0.95;
    opacity: 0;
  }

  /* Leave slightly faster than it arrives */
  &[data-ending-style] {
    transition:
      scale var(--cl-duration-fast) var(--cl-ease-exit),
      opacity var(--cl-duration-fast) var(--cl-ease-exit);
  }

  @media (prefers-reduced-motion: reduce) {
    &,
    &[data-starting-style],
    &[data-ending-style] {
      scale: 1;
      transition: opacity var(--cl-duration-base);
    }
  }
}

Here is another example that is a little more bold, and demonstrates the power unlocked by the new approach. It's the same two attributes, so anything CSS can animate works, whether that's scale, rotation, a 3D tilt, or a custom spring.

.cl-user-button-popover {
  transform: perspective(900px) rotateX(0deg);
  transform-origin: top center; /* the hinge is the menu's top edge */
  transition:
    transform 420ms cubic-bezier(0.34, 1.3, 0.64, 1),
    /* overshoots slightly */ opacity 160ms ease-out;

  &[data-starting-style],
  &[data-ending-style] {
    transform: perspective(900px) rotateX(-70deg);
    opacity: 0;
  }

  /* Fold back up faster than it opened */
  &[data-ending-style] {
    transition:
      transform 200ms cubic-bezier(0.5, 0, 0.75, 0),
      opacity 160ms ease-in;
  }

  @media (prefers-reduced-motion: reduce) {
    &,
    &[data-starting-style],
    &[data-ending-style] {
      transform: none;
      transition: opacity 120ms;
    }
  }
}

This assumes the menu opens below the button, so its top edge is the hinge. The default enter and exit animates transform, so setting transform yourself replaces it directly, and it wins because your layer comes later. If you animate individual properties like scale or rotate instead, reset transform first. Otherwise they stack on top of the default.

Easy for agents to work in

Everything above can live in one CSS file. The component stays a few lines of props, and the look sits in plain CSS with stable class names, data-* state attributes, and --cl-* tokens.

That makes it easier for an agent to work in. It can inspect the DOM to see what it can target, read one stylesheet to see what's already overridden, and write a few rules in a later layer without out-specifying the library. There's no styling API to learn beyond class names, attributes, and tokens, which agents already know well.

The 2.0 components are experimental and subject to change, so we haven't documented them properly yet. Until they settle, the source is the most accurate reference, and your agent can read it directly. The class names, attributes, and tokens are all in there.

Ready to get started?

Start building
Author
Preston Booth

Share this article

Share to socials: