Skip to content

Theming

Two questions come up on every new application: which component library, and what happens in dark mode. The answers are related, because both are decided by the same layer of custom properties.

Pick a component library, or neither

WayneUI does not ship a copy of its components per framework. There is one set of WSU components, built on one layer of semantic --wsu-* custom properties. daisyUI and shadcn-vue are adapters over that layer, and each is about 90 lines of variable aliases.

primitives   --wsu-green-*, --wsu-gold-*, --wsu-neutral-*
     |
semantics    --wsu-brand, --wsu-surface, --wsu-text, --wsu-focus-ring
     |
     +-- daisyUI adapter    --color-primary: var(--wsu-brand)
     +-- shadcn adapter     --primary:       var(--wsu-brand)
     +-- WSU components     .wsu-btn--primary { background: var(--wsu-brand) }

So <button class="btn btn-primary">, <Button> from shadcn-vue and <button class="wsu-btn wsu-btn--primary"> all come out PMS 561c, from the same source. Change the token and all three change.

ConsiderationdaisyUIshadcn-vueNeither
NeedsTailwind 4, no JS buildVite, Vue 3Nothing
Components arrive asClass namesFiles copied into your repoClass names
You customize byUtility classesEditing the copied fileYour own CSS
Good forBlade, raw PHP, static pagesInertia and Vue applicationsAnything

The WSU chrome works against wayne-ui.css alone. If your application is a few forms and a table, that is the whole answer and you can skip the rest of this section.

daisyUI

css
@import "tailwindcss";
@plugin "daisyui" { themes: wsu --default, wsu-dark --prefersdark; }
@import "@waynestate/wayne-ui-css";
@import "@waynestate/wayne-ui-css/daisy";

The fourth line is the adapter itself. Without it the build still succeeds, with no warning, and every daisyUI color variable is left undefined: btn-primary resolves to a transparent background rather than either palette, because the wsu theme named on the second line is only defined inside this import.

The adapter registers two daisyUI themes, wsu and wsu-dark, whose colors are aliases:

css
@plugin "daisyui/theme" {
  name: "wsu";
  --color-primary:           var(--wsu-brand);
  --color-primary-content:   var(--wsu-on-brand);
  --color-secondary:         var(--wsu-accent);
  --color-secondary-content: var(--wsu-on-accent);
  --radius-field:            var(--wsu-radius-field);
  --size-field:              0.3125rem;
}

Two of those lines are not aliases and are worth knowing about:

  • --color-secondary-content is ink, never white. daisyUI's "secondary" is the second brand color, which for Wayne State is gold, and gold sits at 1.5:1 against white.
  • --size-field: 0.3125rem lands daisyUI's default button at 50px, past the 44px WCAG 2.5.5 AAA target, rather than its stock 40px.

The adapter also overrides daisyUI's focus ring. daisyUI draws it from --color-base-content at partial opacity, which is weaker than 2.4.13 requires and is not guaranteed to contrast with the control it surrounds. WayneUI's single focus treatment wins.

The dark theme restates the same aliases with no second palette behind them. The values differ only because the --wsu-* tokens themselves are redefined under [data-theme='wsu-dark'].

shadcn-vue

css
@import "tailwindcss";
@import "@waynestate/wayne-ui-css";
@import "@waynestate/wayne-ui-css/shadcn";

shadcn-vue components read a fixed set of variables. The adapter points every one of them at a WSU token, including the whole --sidebar-* block, so a shadcn sidebar and a WayneUI sidebar are indistinguishable:

css
:root {
  --background: var(--wsu-surface);
  --foreground: var(--wsu-text);
  --primary:    var(--wsu-brand);
  --ring:       var(--wsu-focus-ring);
  --sidebar:    var(--wsu-surface-sunken);
}

The chart series are ordered so that adjacent series differ in lightness as well as hue. A categorical palette separated by hue alone is unreadable to a viewer with color vision deficiency, and a chart has no aria-current to fall back on. Series still need direct labels rather than a legend alone.

The Tailwind bridge uses @theme inline rather than @theme, which is required rather than stylistic: without inline, utilities such as bg-primary bake in whatever value the variable held at build time and the dark theme stops switching.

The registry

The WSU theme and the WSU chrome are published as a shadcn-vue registry at /r on this site:

bash
npx shadcn-vue@latest add https://wayneui.apps.wayne.edu/r/wsu-theme.json
npx shadcn-vue@latest add https://wayneui.apps.wayne.edu/r/wsu-app-bar.json

wsu-theme installs the token layer and the variable mapping. The chrome items copy the component into your repository the way any shadcn-vue component arrives, so you can edit it. What you cannot do from the registry is change the brand: the copied component references semantic tokens, and the tokens come from the package.

Which theme a page opens in

Three steps, in order:

  1. An explicit choice, stored in localStorage under wsu-theme.
  2. prefers-color-scheme: dark.
  3. Light.
js
export function resolveTheme() {
  return storedTheme() ?? (systemPrefersDark() ? DARK : LIGHT)
}

Someone who has set their machine to dark has already said what they want, and a tool that ignores it makes them say it again in every application, in every browser profile, on every new machine. So the operating system is honored until somebody uses the control in the bar, and after that their choice stands, even if the machine later changes.

While nothing is stored the page follows the machine live: the runtime listens for changes to the media query and re-applies, so switching the OS to dark at dusk switches an open tab with it.

Anything unrecognised in storage is discarded rather than trusted, so a corrupted value cannot strand someone in a theme they did not pick and may not know how to leave. Discarding it falls through to the OS preference, not straight to light.

Dark is a real theme, not an afterthought. Every color pairing is contrast tested in both, and the measured ratios are on the Tokens page. Several of the visual defects found while building this system were visible in only one theme, which is why the previews on every component page have their own light and dark switch.

The one exception

The component previews on this site do not follow your machine. Each one starts light and has its own switch, so a screenshot of a preview is the same page for everybody who reads it.

How switching works

Two mechanisms are set at once, because a page may have daisyUI, shadcn-vue, or neither, and WayneUI does not require you to say which:

js
root.setAttribute('data-theme', theme)      // daisyUI reads this
root.classList.toggle('dark', dark)         // Tailwind and shadcn-vue read this
root.classList.toggle('light', !dark)
root.style.colorScheme = dark ? 'dark' : 'light'  // native controls and scrollbars

colorScheme is what makes form controls, scrollbars and the browser's own UI follow. Without it a dark page has light scrollbars and a white date picker.

Applying a theme also fires wsu:themechange on the document element, so a component with a canvas or a third-party widget can react without polling:

js
document.documentElement.addEventListener('wsu:themechange', (event) => {
  chart.setPalette(event.detail.theme === 'wsu-dark' ? dark : light)
})

The pre-paint script

Every path puts a small synchronous script in the <head>, before the stylesheet has finished doing anything visible:

html
<script>
  (function () {
    try {
      var stored = localStorage.getItem('wsu-theme')
      var dark = stored === 'wsu-dark' || (stored !== 'wsu' &&
          matchMedia('(prefers-color-scheme: dark)').matches)
      var root = document.documentElement
      root.setAttribute('data-theme', dark ? 'wsu-dark' : 'wsu')
      root.classList.add(dark ? 'dark' : 'light')
      root.style.colorScheme = dark ? 'dark' : 'light'
    } catch (e) {}
  })()
</script>

Inline and synchronous is the point. An external file, even a blocking one, is a round trip during which the wrong theme is already on screen. The CSS media query covers a page with no stored choice on its own; the script is what stops someone whose stored choice differs from their machine watching the page flip. That flash is unpleasant for everyone and a genuine problem for anyone sensitive to sudden luminance changes.

The try is for private browsing and for storage being disabled, where reading localStorage throws. Nothing is then set on <html>, which leaves the @media (prefers-color-scheme: dark) block in the token CSS to answer, so the page still follows the machine.

The Blade shell emits this for you. The Vue path needs it in the HTML entry file rather than in app.js, because a module script is deferred and by the time the bundle runs the page has already painted.

The official chrome is not restyled

The wayne.edu masthead and footer stay exactly as wayne.edu renders them. They are the university's signature rather than the application's furniture, and they belong to @waynestate/wsuheader and @waynestate/wsufooter rather than to WayneUI.

Adaptations live in one file, src/adapters/official.css. Most of it sits the official chrome correctly next to WayneUI layout and brings its focus indicator up to the standard the rest of the system holds, and none of that touches an official color, size or position.

Two rules go further, because the dependency's own stylesheet leaves a gap for WayneUI to close rather than a choice to leave alone:

  • The footer's stylesheet pins a background and a link color but never a color for its own bare text, so the copyright line falls through to whatever color surrounds it. In dark theme that is close to white over the footer's light gradient, as low as 1.30:1 against the darker stop. The adapter pins that line to #0c5449, the same green the footer already uses for its links: 4.89:1 against the darker stop, 6.17:1 against the lighter one, both past the 4.5:1 this text needs.
  • The dependency disagrees with itself about what the login label is called. dist/header.css hides the span its own markup names wsuloginlabel below 755px, which is what the static, raw PHP and Blade paths render. The Vue component names the same span wsuhidesmall instead and carries its own scoped rule for it, which never reaches the element once the component is built into an application, so "Login" painted in white on top of the person glyph it is supposed to replace. The adapter gives wsuhidesmall the same rule the dependency already gives wsuloginlabel: hidden below 755px, visible above it, where white on the masthead is 12.6:1.

Neither rule changes what the official chrome looks like on the paths the dependency already renders correctly. Both exist because the dependency's own CSS does not cover every path that renders its own markup, and both are documented at the rule itself in official.css.

Consuming them as npm dependencies rather than copying them is what stops the 1.x failure repeating. 1.x pasted a 2017 snapshot into a Blade template and never updated it; the official component has since renamed its wrapper from .wsuwrap to .wsuheaderwrap, and nobody noticed for nine years.

The CSS build also refuses to ship if Zurb Foundation ever appears in the official stylesheets. WayneUI is Tailwind throughout, and the Foundation 6 based waynestate/styleguide is deliberately unused.

Extending the theme

Add semantic tokens; do not reach past them.

css
/* In your application's stylesheet, after wayne-ui.css */
:root {
  --app-chart-grid: var(--wsu-border);
}

[data-theme='wsu-dark'] {
  --app-chart-grid: var(--wsu-border-strong);
}

Two rules make this hold up:

  1. Never reference a ramp value such as --wsu-green-400 in a component. It will be right in light, wrong in dark, and invisible to the contrast gate because no semantic token declares it.
  2. If your new token puts a color behind text, declare its contrast requirement and test it. contrastRatio is exported from @waynestate/wayne-ui-tokens/contrast so your check and the system's check compute the same number.

WayneUI's own CSS uses cascade layers, ordered wsu.base, wsu.components, wsu.chrome. Your application's unlayered CSS overrides all of it, which is the behavior you would expect. Inside the system, a rule that is losing belongs in a later layer rather than in a longer selector, and !important inside a layer inverts the layer order so a rule in base outranks one in chrome.