One dial for your gray
Every neutral surface in Petal Components - page backgrounds, panels, borders, muted text - is written against the
gray
ramp. gray
is a semantic name, not Tailwind's gray: your app decides what it points at, in one block, in your own app.css. Change the target and the entire library follows, light and dark.
@theme inline {
--color-gray-50: var(--color-slate-50);
--color-gray-100: var(--color-slate-100);
--color-gray-200: var(--color-slate-200);
--color-gray-300: var(--color-slate-300);
--color-gray-400: var(--color-slate-400);
--color-gray-500: var(--color-slate-500);
--color-gray-600: var(--color-slate-600);
--color-gray-700: var(--color-slate-700);
--color-gray-800: var(--color-slate-800);
--color-gray-900: var(--color-slate-900);
--color-gray-950: var(--color-slate-950);
}
Swap slate
for zinc, neutral, stone, or any 11-step ramp of your own, and you have rethemed the app. Without the block you get the shipped default, which is zinc: a colourless neutral, the same call shadcn made. Cool-blue apps want slate, warm apps want stone. There is nothing to configure in Elixir and no theme object at runtime - it is CSS variables all the way down.
You can try every neutral live in the dev playground (clone
petal_components
and run mix run dev.exs) - the gray
dial in the top bar restyles the whole page, and the theme lives in the URL.
The materials
Dark mode is built from two materials, and knowing which is which explains every colour you see:
Panels
- pages, cards, dropdown menus, modals, tables - are opaque steps of your gray. Point gray at slate and panels are slate.
The ghost material
- inputs, hover fills, hairline borders, the translucent layer that sits on top of panels - is your
gray
at low opacity. Since v4.6.2 it is carried on the ramp's most colourful step, so it picks up your neutral's temperature instead of reading as colourless next to it. A slate app gets faintly slate inputs; a stone app gets warm ones. On zinc or neutral there is no hue to carry and it looks exactly as it always did.
Neither material is something you set directly. Both derive from the one gray block above - that is the point.
Radius
One custom property drives corner rounding across the library - buttons, inputs, badges, modals, dropdowns, tooltips:
:root {
--pc-radius: 0.625rem; /* the shipped default */
}
/* sharp, industrial */
:root { --pc-radius: 0; }
/* soft, friendly */
:root { --pc-radius: 1rem; }
Components that need a larger or smaller rounding than their neighbours derive it from the same token, so the whole app stays proportionate when you change it.
Type
Components inherit your app's font stack, and that stays the default. Three opt-in tokens exist for when a design wants distinct faces. The library only reads them, never defines them, so an app that sets nothing changes nothing:
/* whole app: Tailwind's own theme font, preflight does the rest */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
/* a heading face distinct from body */
:root {
--pc-font-heading: "Fraunces", ui-serif, Georgia, serif;
}
/* code surfaces follow the mono theme font */
@theme {
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
--pc-font-heading
drives the heading components and prose headings, falling back through
--pc-font-body
so one body token restyles everything. --pc-font-body
covers the reading surfaces: typography, prose, chat. Both re-resolve per subtree, so scoping a face to one section is just setting the token on a wrapper. Key caps deliberately follow the body face, never mono.
The full story, with a live specimen and the self-hosting recipe, is on the Typeset page. The fastest way to a working setup is the playground: dial in faces at playground.petal.build and Get Code emits the complete story, including self-hosted font files with correct per-family weight ranges and a preload line for the root layout.
Primary and the semantic ramps
Colour works the same way as gray: semantic ramp names that your app points at real ramps. primary, secondary, success, danger,
warning
and info
each map to an 11-step ramp in the same @theme inline
block:
@theme inline {
--color-primary-50: var(--color-blue-50);
/* ... one line per step ... */
--color-primary-950: var(--color-blue-950);
--color-success-500: var(--color-green-500);
--color-danger-500: var(--color-red-500);
/* etc */
}
Want the shadcn look? Point primary
at a monochrome ramp - near-black solids in light mode, white in dark. The look lives in the token values, not in component special cases, which is why one block gets you there.
The one rule
If you write your own components alongside ours (or have an AI assistant write them), there is a single rule that keeps everything on the dial:
surfaces use the gray ramp, and nothing references a literal palette name.
The moment something says zinc-800
or slate-900
by name, it stops listening to your theme and will stick out the first time you change it. We learned this the hard way - a handful of our own dark surfaces hardcoded
zinc
and ignored remapped grays until v4.6.1.
Everything here also holds for Petal Pro: Pro's own components are written against the same ramps, so one block rethemes the whole app - library and Pro chrome together.
Overriding one component
The dials above restyle the whole library at once. When a single component has to look different from the rest, override its
pc-
class rather than forking the component. Every component's styles ship in
default.css
inside Tailwind's components
layer, so your own block in that layer, written after the import, wins:
@import "tailwindcss";
@import "../../deps/petal_components/assets/default.css";
@layer components {
/* square, uppercase buttons, everywhere */
.pc-button {
@apply rounded-none font-semibold tracking-wider uppercase;
}
/* one variant only */
.pc-button--primary {
@apply border-black text-black;
}
}
Same layer, later in the file, same specificity: yours wins. Staying inside
@layer components
is the part worth getting right. Utilities outrank that layer, so a per-call
class="rounded-full"
still wins on the one button that needs it. An unlayered rule would beat the utility too, and you would lose that escape hatch.
Names follow the block-and-modifier shape: pc-button
carries the geometry every button shares, pc-button--primary
and pc-button--lg
carry the differences. Inspect the element to find the class you want, or read
default.css
in the package.
If you came here from the old Tailwind v3 advice: the !leading-1
prefix form is gone. Tailwind v4 writes the important marker at the end (leading-1!), and you rarely need it, because utilities already outrank component styles.