rapttor / yii2-themes
Per-user themes for Yii2: complete light, dark and soft (neumorphic) colour sets picked by id, picker widget, contrast-checked, with themes extracted from artworks
Package info
github.com/rapttor/yii2-themes
Language:JavaScript
Type:yii2-extension
pkg:composer/rapttor/yii2-themes
Requires
- php: >=8.2
- yiisoft/yii2: ~2.0.54
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Per-user themes for Yii2 apps. A theme is a complete set of six colours (background, surface, text, highlight, link,
accent) with its own mode (light or dark) and style (flat or soft/neumorphic), picked by id. 81 built in, in 14 groups, plus the user-defined custom:
| Group | Themes |
|---|---|
| Device | system: follows the device, by default Human Light by day and Human Dark at night |
| Design systems | human-light, human-dark (Apple Human Interface system colours), material-light, material-dark (Material 3 baseline) |
| RapTToR | rapttor-dark (near-black and lime, as rapttor.com), rapttor-forest (dark green and lime), rapttor-light (light grey, green text and links) |
| Accessibility | high-contrast-light, high-contrast-dark (12:1 or better, strong lines), colorblind-light, colorblind-dark (Okabe-Ito accents), paper (warm, low glare), sepia-night |
| Minimal | mono-light, mono-dark, newsprint, blueprint |
| Soft | soft-light, soft-dark, soft-neutral: neumorphic, depth from light and dark shadows |
| Glass | glass-frost, glass-smoke, glass-aurora, glass-sunset, glass-ocean, glass-rose: translucent surfaces with backdrop blur over a soft colour page |
| Materials | slate, concrete, terracotta, linen, chalkboard, graphite |
| Nature | forest, ocean, desert, aurora, volcano |
| Seasons | spring, summer, autumn, winter |
| Times of day | sunrise, golden-hour, dusk, midnight |
| Places | tokyo, lisbon, marrakesh |
| Editors | solarized-light, solarized-dark, nord, nord-light, gruvbox-light, gruvbox-dark, dracula, tokyo-night, catppuccin-latte, catppuccin-mocha, primer-light, primer-dark: the well-known editor palettes, adapted to the contrast rules with their hues kept (credit names the original, all MIT) |
| Art | starry-night, great-wave, water-lilies, the-kiss, pearl, composition, sunflowers, cafe-terrace, impression, the-scream, red-fuji, birth-of-venus, composition-vii, milkmaid, poppy-field, almond-blossom: colours extracted from public-domain paintings |
| Retro | terminal-green, amber-crt, game-boy, synthwave |
Every theme is tested against the same contrast rules: text 7:1 on background and surface, muted text, highlight and link 4.5:1, accent 3:1, readable ink on the highlight colour.
Install
composer require rapttor/yii2-themes
// config: application component 'components' => ['themes' => ['class' => rapttor\themes\Themes::class]],
One column where you store the choice: theme varchar(32) not null default 'system'.
Use
// layout, before the head is rendered $themes = Yii::$app->themes; $id = $themes->normalize($identity?->theme); $themes->registerHead($this, $id); // assets, the theme's CSS variables, theme-color meta // <html lang="en" <?= $themes->htmlAttributes($id) ?>> // model / form public function rules(): array { return [...Yii::$app->themes->rules(), /* ... */]; } // ActiveForm <?= rapttor\themes\widgets\ThemePicker::widget(['form' => $form, 'model' => $model]) ?>
The page styles itself with CSS variables: --bg --surface --text --muted --line --brand --brand-ink --link --accent
and the depth tokens --elev --elev-sm --elev-in. Hooks on <html>: data-theme="<id>", data-mode="light|dark"
(absent for system), data-style="soft".
Glass themes (data-style="glass"): use background: var(--glass-bg); backdrop-filter: var(--glass-filter); -webkit-backdrop-filter: var(--glass-filter); border: 1px solid var(--glass-line); box-shadow: var(--elev)
on raised surfaces. Other styles set --glass-bg to the plain surface and the filter to none, so one stylesheet serves all. The page gets a colour gradient
(--page-bg) for the glass to blur; browsers without backdrop-filter, and people who ask for reduced transparency, get opaque surfaces. The audit checks text, muted
text, highlight and link on the glass at the worst spots of that gradient, not only on the flat colours.
Soft themes: apply box-shadow: var(--elev) to raised surfaces (cards, panels, buttons: --elev-sm) and
var(--elev-in) to inset ones (inputs, pressed buttons). Flat themes set all three to none, so the same CSS fits both.
Soft shadows are low contrast by design: keep text and input outlines legible (the package guarantees the text colours).
Choosing in the picker previews live (theme-picker.js); saving is your form's job.
Web app manifest: $themes->metaColors($id)[0] is the splash background, $themes->resolved($id)['brand'] the theme colour.
Configure
'themes' => [ 'class' => rapttor\themes\Themes::class, 'default' => 'system', 'systemLight' => 'material-light', // what `system` shows by day / at night 'systemDark' => 'material-dark', 'themes' => [ 'mine' => ['label' => 'Mine', 'group' => 'custom', 'mode' => 'light', 'bg' => '#ffffff', 'surface' => '#f5f5f5', 'text' => '#111111', 'brand' => '#0044aa', 'link' => '#0044aa', 'accent' => '#aa4400'], 'pearl' => null, // remove a built-in ], ],
Theme fields: label, group, mode (light|dark), optional style (soft), bg, surface, text, brand, link,
accent, optional credit. Muted text, lines, the ink on the highlight colour and the shadows are derived
(Themes::resolved()).
Packs and how they were made
The art themes come from tools/art-themes.php (extracted painting colours), the Seasons / Times of day / Nature / Materials /
Minimal / Places packs from tools/packs.php (six colours with a mood each, then adapt()); both print the rows for
Palettes.php, and packs.php --app prints rows for a host app's own groups (holidays, pet coats, ...).
Fitting an existing palette
ThemeBuilder::adapt($theme) takes a palette you already have (an editor theme, a brand guide) with mode, bg, surface,
text, brand, link, accent and moves only the lightness of text, brand, link and accent until they pass the rules,
so the hues stay recognisable. The editor and retro themes were made this way.
Picker look
With about a hundred themes the picker never lists them all at once: the Palette tab shows one category at a time and opens on the category of the current choice (else the first). The All chip goes last by default; 'allTab' => 'first' moves it to the front and 'allTab' => false removes it, so the picker can only show one category.
'pickerLayout' => 'auto' (default), 'tabs' or 'list' on the component, or ThemePicker::widget([..., 'layout' => 'list']) per
form:
tabs: a row of category chips (All, RapTToR, Design systems, ..., Custom), one category at a time; System stays above it and the page opens on the category of the current choice. Short, and it scrolls sideways on a phone.list: every group stacked with its heading, nothing hidden.auto: chips when more thantabsAbove(default 30) themes are offered, otherwise the list.
Picker
The picker has an All / Light / Dark filter and, in tabs layout, category chips (see Picker look). Add 'assetManager' => ['appendTimestamp' => true] in the host so updated package
assets are not served from a stale browser cache.
Themes from an image
ThemeBuilder::fromDominant(['#171d20', '#5b7392', ...], 'dark') turns the dominant colours of any image into a theme:
the image gives the character (tint of the background, highlight, link and accent hue), then every role's lightness is
fixed so the result is readable. The built-in art themes were made this way from k-means clusters of public-domain
paintings; credit names the artwork.
Backgrounds
The picker has four tabs: Palette (the colour themes), Patterns (static backgrounds, solid colour, gradients), Live (animated) and Photos (Unsplash; only when a key is set). The overlay, liquid and palette controls sit under Patterns, Live and Photos. Hide the background tabs with 'backgroundsEnabled' => false:
- Static: Blueprint, Chalkboard, Graphite, Graph paper, Dot grid, Midnight, or any solid colour.
- Gradients: seven presets and a custom two-colour gradient with an angle.
- Animated (48 in five categories, no libraries, loaded on demand, paused in a hidden tab, still for
prefers-reduced-motion; the Live tab shows one category at a time). Nature: aurora, waves, bubbles, snowfall, rain, clouds, petals, birds, fog, kelp, sunbeams, jellyfish, storm. Space: night sky, starfield, galaxy, meteors, orbits, tunnel. Abstract: gradient flow, liquid blobs, ripples, contour lines, plasma, swirl, ribbons, rings, low poly, pointer glow (follows the pointer), scroll hue (shifts with scrolling), gradient sea, psychedelic, prism currents, kaleidoscope, spirograph. Tech: particle network, digital rain, neon grid, dot wave, hex grid, circuit, terrain, equalizer (animated bars only, no microphone). Sparks: fireflies, embers, floating dust, fireworks, squares. They draw in the colours of the current theme; give your own anassets/bg/<id>.jsand an entry (with agroup) inbackgroundItems['anim']. - Photos from Unsplash (search, topic chips).
- Overlay: none / white / black at 0 to 90 % so content stays readable, and a Remove background button that returns to the plain colour theme.
- Liquid interface (optional, off by default): with a background active, the main containers (
main,.container,.container-fluid, ... seeliquidSelectors) and every surface that uses--glass-bgbecome semi-transparent frosted glass (0-70 % transparency, backdrop blur), so the background shows through.'liquidDefault' => 40starts new backgrounds with it on. Only the outermost matching container is changed; add your layout's wrapper toliquidSelectorsor give itdata-liquid-surface. - Use this background's colours (optional): a background can carry its own palette (Blueprint brings the Blueprint theme, Chalkboard the Chalkboard theme, a solid colour a theme built from it). For a photo the browser extracts its five dominant colours (median cut, most dominant first, shown as swatches) and the server builds two versions from them with
ThemeBuilder::fromDominant(), Dark and Light, both held to the contrast rules (a bright photo starts on Light). While ticked the page shows that palette; the chosen theme stays stored.
It is remembered in a first-party cookie (theme_bg, a year, HttpOnly, Lax; list it on your cookie page) and, if the user row has a theme_bg column
(the package migration adds it, optional), on the account too, so it follows the user across devices.
// layout: nothing new, prepare() handles theme, custom colour and background <html lang="en" <?= Yii::$app->themes->prepare($this, Yii::$app->user->identity) ?>> // controller: add the photo search next to the theme page 'background-search' => rapttor\themes\actions\BackgroundSearchAction::class,
ThemeAction saves it and passes searchUrl to the view; with your own form use ThemePicker::widget([..., 'searchUrl' => Url::to(['background-search'])])
and a theme_bg attribute (...Yii::$app->themes->rules() validates it).
Unsplash: put UNSPLASH_ACCESS_KEY=... in the environment (.env) or set 'unsplashKey' on the component. The key stays on the server: the browser only
calls your background-search action, results are cached for an hour and the action allows 40 requests a minute per client. Without a key the photo section is
hidden. Unsplash's demo tier allows 50 requests an hour; apply for production access before you launch. The package follows their guidelines: photos are hotlinked from
images.unsplash.com, the photo and photographer are credited at the bottom of the page (set 'appName' for the utm_source), and the download ping is sent
when a photo is chosen. Only images.unsplash.com paths are ever accepted, never free URLs.
CSS contract (in theme.css, nothing to write in the host): html[data-bg] paints the still layer and the overlay behind the page and makes body transparent;
a wrapper that paints its own background: var(--bg) hides it, so leave page wrappers transparent. Hooks: data-bg="static|gradient|solid|anim|img", data-bg-id,
variables --tbg-image and --tbg-overlay.
Your own backgrounds: 'backgroundItems' => ['static' => ['brand' => ['label' => 'Brand', 'css' => 'linear-gradient(...)', 'theme' => 'brand-dark']]] (null removes a built-in).
A new animated effect is a file assets/bg/<id>.js calling RapttorBg.register(id, {start(layer, ctx) { ...; return stop; }}) (see theme-bg.js for ctx) plus an entry in
Backgrounds::ANIMATED; tools/bg-preview.html shows them all. tools/demo/ is a runnable demo app (php -S 127.0.0.1:8100 -t tools/demo tools/demo/index.php).
Less copying per app
Everything below is optional; with it a new app needs about five lines of config.
// config/web.php 'components' => ['themes' => ['class' => rapttor\themes\Themes::class, 'syncOnLogin' => true, 'baseStyles' => true]], // a controller: the theme page and the live preview of the user-defined theme public function actions(): array { return [ 'theme' => rapttor\themes\actions\ThemeAction::class, // 'view' => 'your/view' to use your own page 'theme-preview' => rapttor\themes\actions\ThemePreviewAction::class, ]; } // layout: assets, variables, theme-color and the <html> attributes in one call <html lang="en" <?= Yii::$app->themes->prepare($this, Yii::$app->user->identity) ?>>
- Columns:
./yii migratefinds the package migration on its own (Bootstrap, through Composer); table =Themes::$userTable(default{{%user}}). - Sign-in sync:
'syncOnLogin' => true(or call$themes->adopt($identity)yourself): an account that still has the default takes the visitor's cookie choice. - Contrast in CI:
./yii themes/auditexits 1 when a theme (yours included) breaks the rules. - Aliases:
'aliases' => ['old-id' => 'new-id']keeps a stored id working after a rename or merge (the package's own inPalettes::ALIASES). - prefers-contrast:
systemswitches to High Contrast for people whose device asks for more contrast;'systemMoreContrastLight' => nullturns it off. - Base stylesheet:
'baseStyles' => truestyles body, links, buttons, inputs and cards from the tokens (soft shadows and glass included).
Per-app themes and groups
Every app can add its own themes and picker groups in the component config (see Configure): themes adds, overrides or
removes (null), groups names your own group ids. They come right after the System option, before RapTToR and the other built-in groups,
in the order you list them; add 'position' => 'bottom' to put one after the built-in groups instead (a plain string is a top group):
'groups' => ['brand' => 'Our brand', 'coats' => ['label' => 'Pet coats', 'position' => 'bottom']], 'themes' => ['brand-dark' => ['label' => 'Brand Dark', 'group' => 'brand', 'mode' => 'dark', /* bg surface text brand link accent */]],
The user-defined theme
The reserved id custom is a theme built from one colour the user picks plus light or dark: that colour becomes the
highlight and tints the page, a nearby hue the link, a far hue the accent; greys give a neutral theme. It is readable for
any colour (ThemeBuilder::fromColor(); audit() samples 216 colours). The value is a string dark:#336699.
Host: a nullable theme_custom varchar(16) column next to theme, ...Yii::$app->themes->rules() (validates and normalises
it), and pass it along: $themes->registerHead($this, $id, $custom), htmlAttributes($id, $custom), resolved($id, $custom),
remember($response, $request, $id, $custom) (second cookie theme_custom), and for rendering
$custom = $themes->customFor($identity ? (string) $identity->theme_custom : null, $request).
The picker shows it as a card of its own below all the groups (the Custom tile, a colour input and a Light/Dark switch); give it a previewUrl for the live preview,
an action that returns $themes->preview($themes->normalize($theme), $custom) as JSON. Without it the colour previews
after saving. Hide the whole feature with enabledGroups (leave custom out).
Order of the picker
Device (system), then your own groups (the default; 'position' => 'top'), then the built-in groups with RapTToR first and
the rest after it, then your groups marked 'position' => 'bottom', and finally the user-defined card (when enabled).
Choosing which categories to offer
An app does not have to offer everything. List the group ids to show; everything else disappears from the picker and from
validation (per-theme control is the 'id' => null entry in themes, no extra option needed):
'enabledGroups' => ['design', 'accessibility', 'brand', 'custom'], // null (default) = all groups
Built-in group ids: design, rapttor, accessibility, minimal, soft, materials, nature, seasons, daytime, places, editors, art, retro, plus your own (brand, ...) and
the reserved custom for user-defined themes. The system option is always offered, and keeps working even when the group
of its light/dark pair (systemLight, systemDark) is hidden. A user who had a theme of a hidden group stored simply gets
the default.
Yii::$app->themes->audit() returns the readability violations of every configured theme (empty array = all good); put
assertSame([], Yii::$app->themes->audit()) in the host app's tests so its own themes are held to the same rules.
Remembering the choice for visitors
Signed-in users store the id in their row. For visitors (and the pages around sign-in) keep it in a first-party cookie:
$id = Yii::$app->themes->resolveFor($identity?->theme, Yii::$app->request); // user's theme, else cookie, else default Yii::$app->themes->remember(Yii::$app->response, Yii::$app->request, $id); // after the user picks one (1 year, HttpOnly, Lax) $cookieId = Yii::$app->themes->fromRequest(Yii::$app->request); // validated id or null
Set the cookie only as a result of the user's own choice (it is a user-interface preference, normally exempt from consent
banners; list it on your cookie page). When someone signs in for the first time, adopt fromRequest() as their theme if they
have none yet. The cookie name is $themes->cookieName (default theme).
Translations
Group headings and the system label use the message category themes (messages/<lang>/themes.php: ar de es fr it ja ko nl
pl pt ru sh sv tr zh; machine translations, need native review). Theme names are proper names and are not translated.
For AI coding agents see AGENTS.md and llms.txt.
Tests
vendor/bin/phpunit -c phpunit.xml
Credits
Human and Material themes follow the public colour guidance of Apple and Google; the art themes use colours extracted
from public-domain paintings (see credit in Palettes.php). License: MIT.