Theming
Poetry ships nine complete themes: the upstream shadcn styles at the
shadcn@4.19.0 release, translated onto the
cn-* layer. An app installs
one; this site renders all nine. Use the style picker in the header to
view any page in any theme.
The nine themes
Themes change shape and texture only: radii, spacing, borders, type
scale, scrims. Color tokens live one layer down
(tokens.css) and are shared, so light/dark mode is
orthogonal to the theme choice.
| Theme | Character |
|---|---|
default | Poetry's original look: the new-york-v4 lineage the library was built against |
vega | closest to default: ring-based surfaces, subtle blur scrims, xl radii |
nova | compact: tighter paddings and smaller surfaces |
mira | compact and quiet: relaxed xs body text, heavily dimmed drawer scrims |
rhea | soft and elevated: capped 4xl radii, shadow-xl, blurred scrims |
maia | fully rounded: 4xl surfaces, deeply dimmed scrims |
luma | soft-round: blurred scrims and a floating drawer frame |
lyra | boxy and sharp: radius zero, type one step down, made for mono fonts |
sera | editorial: uppercase tracked type, underline-only fields, serif pairing |
Installing a theme
An app carries one theme. The installer copies the
chosen fragment into a fixed slot; re-run with a different
--theme to switch; the slot filename never changes, so
nothing else in your app moves.
bin/rails g poetry:install --charts --theme vega
# app/assets/tailwind/poetry/style-default.css <- vega's bytes land here
# app/assets/tailwind/poetry/style-charts.css <- and the chart-side match
Fragments import into layer(base), so utility classes
you write in templates always win over the theme.
Owning the design outright
The vendored slot is refreshed on every install re-run, which keeps upstream visual updates flowing but means edits to it are overwritten. To own the design instead, copy the fragment out of the slot and point the entry import at your copy:
/* 1. copy app/assets/tailwind/poetry/style-default.css to style-acme.css
2. point the entry import at your copy (keep layer(base)) */
@import "./poetry/style-acme.css" layer(base);
Then restyle freely. The markup never changes, because components emit
stable cn-* names plus structural classes only: every
component's visual treatment is a named rule
(.cn-button, .cn-card-header), and each
variant and size has its own class
(.cn-button-variant-destructive,
.cn-button-size-lg). Two orders matter when editing a
copy. Inside the theme, order is meaning: per component, base rules
come before element, variant, and compound rules so the later ones
win, and rules that restyle another component's surface sit
in a closing section after everything they override. And in the
cascade, any unlayered rule you write beats layer(base);
against the shipped theme that power is the declared-override channel
described under "Overriding the theme" below.
How this site renders all nine
When two or more themes must coexist, each fragment is wrapped in a
.style-<name> block and every wrapped fragment is
imported together, the same mechanism the upstream shadcn app uses.
Exactly one style-<name> class sits on
<html> at all times (Poetry fragments are
complete visual layers, so a lone unscoped theme would leak tokens
into whichever theme is stamped).
/* style-registry.css */
@import "./styles/style-vega.css" layer(base); /* .style-vega { .cn-* rules } */
@custom-variant style-vega (&:where(.style-vega *));
The @custom-variant half also enables per-style opt-ins
in markup. This card rounds off under vega only:
class="style-vega:rounded-none rounded-lg border p-4"
The font pairing
No Poetry theme moves a font token; upstream's fragments don't either. The pairings are metadata from upstream's create flow: lyra is biased hard to mono (JetBrains Mono, radius forced to zero) and sera keeps serif company (Noto Serif, Instrument Serif). lyra and sera move their typography through size, weight, tracking and uppercase utilities, which port verbatim; the family is your call.
This site honors the pairing with system stacks keyed off the same switcher class: pick lyra or sera in the header and watch the page follow:
.style-lyra { font-family: ui-monospace, SFMono-Regular, Menlo, ...; }
.style-sera { font-family: ui-serif, Georgia, Cambria, ...; }
In your own app, set the family alongside the install; e.g. load
JetBrains Mono and point --font-sans at it when
installing lyra. The same vars feed
typeset presets.
Overriding the theme
Utility classes in your templates always win over the theme
(fragments import into layer(base)), and token-level
restyling has its own channel:
poetry:design:import writes
design-overrides.css from a DESIGN.md. Writing your own
CSS against theme-owned cn-* classes is the third,
sharpest tool: allowed, but it must be declared, a
dated, reasoned, file-scoped entry under overrides: in
config/poetry_components.yml.
bin/rails poetry:design:overrides reports both
directions: undeclared cn-* rules (drift; the report
prints a paste-ready declaration) and stale declarations that no
longer match any CSS. Declared overrides travel into the exported
DESIGN.md as an "Intentional deviations" section, so design intent
is versioned with the design. This site dogfoods the contract: the
style switcher's nine scoped theme copies are one declared override.
DESIGN.md interop
DESIGN.md is the design-skill ecosystem's shared artifact — design skills across the ecosystem study a brand and emit one, and Poetry both writes and reads the format. The loop end-to-end: study a brand with any design skill, hand the resulting file to Poetry, and the app themes itself, with the same contrast gate Poetry's own tokens live under.
# write this app's design as the shared artifact
bin/rails poetry:design:export # -> DESIGN.md (tokens + treatment + both modes)
# read a brand's DESIGN.md into token overrides
bin/rails 'poetry:design:import[path/to/DESIGN.md]'
Import maps role names conservatively (Poetry roles, a short alias
set like brand → primary, and Material-style
on-* pairs); everything else is listed in the report
and dropped, never guessed. Every token-pair the import touches is
measured against the WCAG AA floor on the merged set; a failing pair
is dropped with a deterministic nearest-passing suggestion (an OKLCH
lightness walk, chroma held), and only
POETRY_DESIGN_FORCE=1 ships one. Fonts never enter CSS
(the pairing above stays metadata); a light-only palette gets its
dark values pinned to the Poetry defaults so the overrides can't
leak across modes. The result lands in
app/assets/tailwind/poetry/design-overrides.css, wired
after the theme imports; POETRY_DESIGN_JSON=1 emits the
report as JSON for tooling.
What stays out of the theme
Behavioral and structural classes stay in markup
(inline-flex, open:grid,
disabled:pointer-events-none, positioning vars, the
slider's geometry chain): a theme can restyle every surface but can
never break behavior. A few components are machinery with no
cn-* surface at all: AspectRatio, Separator, Spinner,
ScrollArea, and the chart Container, with Carousel and Resizable close
behind (only their navigation buttons and handle icon carry theme
rules). Restyle those via data-slot selectors or
class:. The chart palette also stays out: series colors
ride tokens (--chart-1 through --chart-5),
marks take var(--color-<key>), and chart chrome
(grid, ticks, cursor, tooltip, legend) ships as its own fragment,
style-charts.css, shared across chart families.
Consumer utilities
Two standalone classes the theme defines for direct use in your own
markup. .cn-font-heading is the heading-font hook: a
no-op until you define --font-heading (it falls back to
--font-sans); Empty's title wears it, and you can put it
on any element that should follow the heading face.
.cn-rtl-flip mirrors an icon under RTL; Pagination and
Breadcrumb chevrons wear it.
Color scheme (dark mode)
Every theme ships both modes: the tokens define :root
(light) and .dark overrides, and both blocks carry
color-scheme, so scrollbars, form-control chrome, and
canvas defaults follow the app's mode rather than the OS preference.
What the gem does not decide is when .dark
applies; that part is one line in the host layout, rendered inside
<head> before the stylesheets:
<head>
<%= poetry_color_scheme_script %>
<%# ...stylesheet and javascript tags... %>
</head>
Before first paint it reads the stored preference, falls back to the
OS setting, and toggles .dark on <html>,
so a refresh never flashes light mode, and an unset preference follows
the OS live, including mid-session switches. Because the class lives
on <html>, Turbo visits and back-button restores
keep the mode with no re-application. The script also installs a
switch API; wire any control to it:
<%= poetry_button(variant: :ghost, size: :icon, label: "Toggle dark mode",
onclick: "Poetry.colorScheme.toggle()") do %>
<%= poetry_icon(name: :"sun-moon") %>
<% end %>
Poetry.colorScheme.current() reads the active mode,
set("dark") / set("light") pins one,
toggle() flips, and clear() returns to
following the OS. Every change dispatches a
poetry:color-scheme event (bubbling, with the mode in
detail) for anything that must redraw with the mode:
chart recoloring, embedded editors, maps.
Portals and scoped themes
Floating overlays (tooltip, hover card, popover, select, combobox,
the menus) move to <body> while open and return
home on close, so no clipping or transformed ancestor can trap them.
With a normal install this is invisible to theming: one theme loads
globally, so a body-level popup matches the same rules it matched in
place. The one setup that needs a knob is a theme scoped to a
subtree (a themed section inside a differently themed page).
A popup moved to <body> would leave that scope, so
point it at a container inside the scope instead:
<div class="my-scoped-theme">
<div id="scoped-overlays"></div>
<%= poetry_popover("data-poetry-portal-container": "scoped-overlays") do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open" } %>
<% popover.with_title { "Stays in the scope" } %>
<% end %>
</div>
data-poetry-portal-container names the target by id and
rides the component root. Pick a container that no
overflow: hidden ancestor clips and no transformed
ancestor captures; those two escapes are most of what the move buys.