Appearance
Brand the Unchurn widget without giving it any control over your app’s CSS.
Unchurn renders into a single scoped root (#unchurn-widget-root) and writes CSS variables on that element only. It never touches html, body, :root, your Tailwind layers, or your utility classes. You style the widget the same way you style Stripe Elements — by passing an appearance object when you open it.
import { createUnchurn } from '@unchurn.dev/widget'
const unchurn = createUnchurn({ tokenEndpoint: '/api/unchurn/token' })
unchurn.open({
appearance: {
theme: 'auto',
variables: {
colorPrimary: '#1942F5',
borderRadius: '12px',
fontFamily: 'inherit',
},
},
})If you only set colorPrimary, the widget derives readable hover, active, muted, and foreground colors from it. Most production installs pass three or four tokens.
Mental model
appearance has three keys:
type WidgetAppearanceConfig = {
theme?: 'auto' | 'light' | 'dark' // default: 'auto'
variables?: WidgetAppearanceVariables // applied to BOTH modes
light?: { variables?: WidgetAppearanceVariables } // overrides for light
dark?: { variables?: WidgetAppearanceVariables } // overrides for dark
}Variables resolve in three steps:
- Shared
variablesapply to both light and dark. light.variables/dark.variablesoverride the shared ones for that mode.- Anything unset falls back to the built-in palette.
This is the same merge order Stripe Elements uses for appearance.variables and appearance.rules. Pass shared tokens that work in both modes; use the per-mode buckets only when you need a different color in dark.
theme
| Value | Behavior |
|---|---|
'auto' | (default) Follow the user’s OS. The widget subscribes to prefers-color-scheme and flips live when the user changes their system theme. |
'light' | Force light mode. |
'dark' | Force dark mode. |
'auto' follows the operating system — not your app’s theme toggle. If your app lets the user pick a theme independently (next-themes, a .dark class on html, Radix <Theme>, etc.), pass that value as theme at open time:
'use client'
import { useTheme } from 'next-themes'
import { useUnchurn } from '@unchurn.dev/widget/react'
export function CancelButton() {
const { resolvedTheme } = useTheme() // 'light' | 'dark'
const { show } = useUnchurn({ tokenEndpoint: '/api/unchurn/token' })
return (
<button
onClick={() =>
show({
appearance: {
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
variables: { colorPrimary: 'hsl(var(--primary))' },
},
})
}
>
Cancel
</button>
)
}Read the theme at click time, not at module top-level — otherwise toggling themes after page load won’t update the widget.
Token derivation
You only need to set colorPrimary. The widget produces the rest automatically.
The derivation logic depends on whether your value is a static color (parseable as a color) or a dynamic CSS value (a var(...), color-mix(...), currentColor, etc.):
| Input type | Hover / active | Muted | Foreground |
|---|---|---|---|
Static color (#1942F5, hsl(231 92% 53%), oklch(...), rebeccapurple, …) | Lightness shifts in OKLCH space — perceptually uniform, so derived states stay on-brand. | A separate OKLCH recipe (lightness + chroma reduction) tuned per mode for a tinted background. | If you supply a custom primary without a foreground, a WCAG contrast check picks white or near-black, whichever is more readable. Otherwise the default foreground is used as-is. |
Dynamic CSS value (var(--primary), color-mix(...), currentColor, …) | color-mix(in oklch, <primary>, white/black 10% hover / 16% active). | color-mix(in oklch, <primary>, transparent 88%). | Falls back to the default foreground. Pass colorPrimaryForeground explicitly. |
Rule of thumb: if you pass var(--primary), also pass colorPrimaryForeground. The widget can’t probe a CSS variable’s value at JS time, so it can’t pick a contrast-safe text color for you.
appearance: {
variables: {
colorPrimary: 'hsl(var(--primary))',
colorPrimaryForeground: 'hsl(var(--primary-foreground))', // required for dynamic primaries
},
}Variables reference
All variables are optional. Unset variables fall back to the built-in palette.
Primary (5)
The brand color and its derivatives. Setting only colorPrimary is usually enough.
| Variable | Default (light) | Default (dark) | What it paints |
|---|---|---|---|
colorPrimary | brand blue (OKLCH ≈ #1942F5) | brand blue | CTA fill, selected radio, selected reason row, focus ring base. |
colorPrimaryForeground | white | white | Text and icons on colorPrimary surfaces. |
colorPrimaryHover | derived | derived | CTA hover state. |
colorPrimaryActive | derived | derived | CTA pressed / loading state. |
colorPrimaryMuted | derived | derived | Tinted background for selected rows and radios. |
Surfaces & text (8)
The “everything that isn’t brand” layer. Keep these at the defaults unless you have a specific reason.
| Variable | What it paints |
|---|---|
colorCanvas | The widget panel background. |
colorWell | Inset containers (offer cards, reason rows). |
colorWellStrong | Hovered/strong wells. |
colorText | Primary text. |
colorTextMuted | Secondary text (subtitles, labels). |
colorTextFaint | Tertiary text (helper text, timestamps). |
colorBorder | Subtle hairlines. |
colorBorderStrong | Dividers and stronger borders. |
Secondary, ring, status (6)
| Variable | What it paints |
|---|---|
colorSecondary | Neutral secondary buttons. |
colorSecondaryForeground | Text on colorSecondary. |
colorRing | Focus ring color. |
colorDanger | Destructive action accents (Cancel anyway button). |
colorDangerForeground | Text on colorDanger. |
colorSuccess | Success accents (confirmation screens). |
Layout (2)
| Variable | Default | Notes |
|---|---|---|
borderRadius | 12px | Base radius for buttons, inputs, and the panel. Any CSS <length> or <percentage>. |
fontFamily | inherit | Any CSS font-family value. Defaults to inheriting from your page. |
Supported values
color* variables accept anything the browser parses as a color, plus first-class CSS functions:
'#1942F5' static hex
'rgb(25 66 245)' static rgb
'hsl(231 92% 53%)' static hsl
'oklch(0.55 0.22 265)' static oklch
'rebeccapurple' named color
'var(--brand-primary)' CSS variable (must hold a full color)
'hsl(var(--primary))' CSS variable holding HSL channels
'color-mix(in oklab, #1942F5 80%, white)' dynamic mix
'currentColor' | 'inherit' keywordborderRadius and fontFamily are validated against the CSS spec (via css-tree). Invalid values fall back to the default with a one-time console.warn per unique value. CSS functions like var(--radius), calc(...), env(...), and clamp(8px, 1vw, 14px) pass through untouched.
Tailwind / shadcn note. If your design tokens store only the HSL channels (e.g.
--primary: 231 92% 53%), wrap them:'hsl(var(--primary))'. Passing bare'var(--primary)'expands to the raw channel string, which the browser doesn’t recognise as a color.
Recipes
Stripe-style: one brand color
appearance: {
theme: 'auto',
variables: {
colorPrimary: '#1942F5',
borderRadius: '12px',
fontFamily: 'inherit',
},
}Tailwind / shadcn
appearance: {
theme: 'auto',
variables: {
colorPrimary: 'hsl(var(--primary))',
colorPrimaryForeground: 'hsl(var(--primary-foreground))',
borderRadius: 'var(--radius)',
fontFamily: 'inherit',
},
}Different brand color in dark mode
appearance: {
theme: 'auto',
variables: { borderRadius: '12px', fontFamily: 'inherit' },
light: { variables: { colorPrimary: '#1942F5' } },
dark: { variables: { colorPrimary: '#a78bfa', colorPrimaryForeground: '#18181b' } },
}Plain CSS app (Rails, Laravel, CDN script tag)
:root {
--brand-primary: #1942F5;
--brand-primary-foreground: #ffffff;
}<script>
window.unchurn.open({
/* …token, merchantId, subscriptionId, mode… */
appearance: {
variables: {
colorPrimary: 'var(--brand-primary)',
colorPrimaryForeground: 'var(--brand-primary-foreground)',
borderRadius: '10px',
fontFamily: 'inherit',
},
},
})
</script>TypeScript
The appearance types are exported from both entry points:
import type {
WidgetAppearanceConfig,
WidgetAppearanceVariables,
WidgetAppearanceTheme,
} from '@unchurn.dev/widget' // or '@unchurn.dev/widget/react'const appearance: WidgetAppearanceConfig = {
theme: 'auto',
variables: { colorPrimary: '#1942F5' },
}What Unchurn doesn’t touch
The widget is isolated by design. It does not:
- write CSS variables on
html,body, or:root - import Tailwind’s preflight or any global reset
- inject
<link>tags into your<head> - override your utility classes or design tokens
- require you to import any CSS
The public styling surface is appearance. Internal DOM structure and class names are implementation details and may change without notice.
QA checklist
Before shipping:
- Open the cancel flow from your real billing page (not Storybook).
- Hover the primary CTA — derived hover state should look intentional.
- Click the CTA and let it sit in loading — the spinner should remain visible against
colorPrimaryActive. - Select each reason row — selected state uses
colorPrimaryMutedand should be visible without being shouty. - Toggle OS dark mode (or your app’s theme toggle) and re-open.
- Close the widget — your page’s typography, layout, and colors should be unchanged.
Troubleshooting
The CTA text turns black on hover or while loading.
Your colorPrimary is a dynamic value (CSS variable, color-mix, …) and Unchurn can’t compute a foreground for it. Pass colorPrimaryForeground explicitly.
A Tailwind color “doesn’t render”.
The variable holds only HSL channels, not a full color. Use 'hsl(var(--primary))', not 'var(--primary)'.
Dark mode looks like light mode (or vice versa).
theme: 'auto' follows the OS, not your app’s theme switcher. Pass theme: 'light' | 'dark' from your app’s theme state. Build the appearance object inside the click handler so toggles take effect without a reload.
Console warning: invalid borderRadius value "…".
The value didn’t parse as a CSS <length> or <percentage>. Common cause: bare numbers (borderRadius: 12). Use a string with a unit: '12px'.
Related
- Widget configuration — callbacks, copy, support, customer context.
- React integration
- Vanilla JS / CDN
- Widget API reference