Skip to Content
WidgetAppearance

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:

  1. Shared variables apply to both light and dark.
  2. light.variables / dark.variables override the shared ones for that mode.
  3. 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

ValueBehavior
'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 typeHover / activeMutedForeground
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.

VariableDefault (light)Default (dark)What it paints
colorPrimarybrand blue (OKLCH ≈ #1942F5)brand blueCTA fill, selected radio, selected reason row, focus ring base.
colorPrimaryForegroundwhitewhiteText and icons on colorPrimary surfaces.
colorPrimaryHoverderivedderivedCTA hover state.
colorPrimaryActivederivedderivedCTA pressed / loading state.
colorPrimaryMutedderivedderivedTinted 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.

VariableWhat it paints
colorCanvasThe widget panel background.
colorWellInset containers (offer cards, reason rows).
colorWellStrongHovered/strong wells.
colorTextPrimary text.
colorTextMutedSecondary text (subtitles, labels).
colorTextFaintTertiary text (helper text, timestamps).
colorBorderSubtle hairlines.
colorBorderStrongDividers and stronger borders.

Secondary, ring, status (6)

VariableWhat it paints
colorSecondaryNeutral secondary buttons.
colorSecondaryForegroundText on colorSecondary.
colorRingFocus ring color.
colorDangerDestructive action accents (Cancel anyway button).
colorDangerForegroundText on colorDanger.
colorSuccessSuccess accents (confirmation screens).

Layout (2)

VariableDefaultNotes
borderRadius12pxBase radius for buttons, inputs, and the panel. Any CSS <length> or <percentage>.
fontFamilyinheritAny 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' keyword

borderRadius 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 colorPrimaryMuted and 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'.