Skip to content

Static HTML

No build step, no framework, no package manager. The quickest way onto a page is two tags pointed at the WayneUI CDN, and nothing else. Copying the files into a folder of your own is still here, for the one case that genuinely needs it: a machine with no network at all, or a page opened straight off a network share with no server behind it.

html
<link rel="stylesheet" href="https://wayneui.apps.wayne.edu/v2/wayne-ui.css">
<script type="module" src="https://wayneui.apps.wayne.edu/v2/wayne-ui-theme.js"></script>

That is the whole install, measured in a browser rather than assumed: both tags work cross-origin, and the fonts and the marks the stylesheet references follow along with no extra tags of their own, because a relative url() inside a stylesheet resolves against the stylesheet's own URL, not the page's.

Icons need the script running, not just linked

The sprite is the one file that does not simply work across origins. An SVG <use href="..."> pointing at another origin renders nothing, in every browser, whatever the response headers say: a cross-origin <use> measured this way came back 0 by 0 pixels next to a same-origin control at a real 24 by 24. wayne-ui-theme.js exists in part to fix exactly this. It fetches the sprite itself and rewrites every <use> that points at it to a bare #id, which resolves against the current document instead of asking the network again. Verified end to end across three separate origins: a page on one port with the sprite served from another went from href="http://localhost:4181/v2/icons.svg#wsu-menu" rendering nothing, to href="#wsu-menu" with every symbol injected into the page and the icon rendering at full size.

None of that needs configuring here. The sprite's URL defaults to a sibling of wayne-ui-theme.js itself, worked out from the script's own URL at run time, so pointing the script tag at the CDN moves the sprite with it for free. Write every <use> the same way you always have, with the full sprite URL and the fragment:

html
<svg class="wsu-icon" aria-hidden="true">
  <use href="https://wayneui.apps.wayne.edu/v2/icons.svg#wsu-search"/>
</svg>

Never write a bare #id

Do not write <use href="#wsu-search"> on the assumption that the runtime rewrite will make it work. The rewrite only touches a <use> that already carries the full sprite URL matching the one the script resolved for itself; a fragment that never pointed anywhere is not something it can find and correct. An icon authored this way is blank the instant JavaScript has not run yet, is slow, or has failed, with nothing to fall back to.

Preload it, if the connection is slow

A <link rel="preload"> for the stylesheet and the script pays for itself on a slow connection, but only when it is written into your own <head>, before the tags it is warming up:

html
<link rel="preload" as="style" href="https://wayneui.apps.wayne.edu/v2/wayne-ui.css">
<link rel="preload" as="script" crossorigin href="https://wayneui.apps.wayne.edu/v2/wayne-ui-theme.js">

wayne-ui-theme.js cannot emit this hint for you. A preload only helps when the browser discovers it before it reaches the resource being described, and by the time the script is running, the browser has already reached it.

Content-Security-Policy has to name the CDN

If your page sets a policy, and it should, default-src 'self' on its own blocks more of this than the stylesheet link. With no connect-src of its own, a policy inherits default-src for it, and the icon sprite's fetch is refused before CORS is ever considered:

Content-Security-Policy:
  default-src 'self';
  style-src 'self' https://wayneui.apps.wayne.edu;
  style-src-attr 'unsafe-inline';
  script-src 'self' 'sha256-cBln7aeEDiDg4zAGxdFq/2z9o3hjBN6Chx19/eu3pdQ=' https://wayneui.apps.wayne.edu;
  font-src 'self' https://wayneui.apps.wayne.edu;
  img-src 'self' data: https://wayneui.apps.wayne.edu;
  connect-src 'self' https://wayneui.apps.wayne.edu;

style-src and script-src are what let the two tags above load at all. font-src covers the self-hosted Lato files the stylesheet pulls in. img-src covers the footer's Warrior Strong mark, a CSS background image rather than an <img>, and needs data: on top of the CDN host: wayne-ui.css draws the Browser mockup's magnifying-glass icon from a base64-encoded mask-image, on either template, with nothing in your own markup to reveal that it needs it. connect-src is what lets wayne-ui-theme.js fetch the sprite.

style-src-attr 'unsafe-inline' is a separate allowance from style-src itself, and this page needs it: an inline style="..." attribute, the pattern the reference applications at apps/examples/static-html/index.html and public-app.html use throughout for spacing between blocks such as .wsu-carousel and .wsu-rotator, is silently discarded without it. Nothing on screen explains this: no broken image, no missing block, only a margin that measures 0px where the markup plainly says otherwise. The console does say so, with a refusal naming the directive, so this is findable the moment somebody thinks to look there. It is the screen that gives nothing away. This is narrower than 'unsafe-inline' on style-src, which stays off above: it permits only the style attribute, not a <style> block or an injected stylesheet, which style-src still governs.

The sha256- value on script-src is not about the CDN at all. It is what lets the pre-paint theme script in the next section run. script-src above carries no 'unsafe-inline', on purpose, so a browser refuses to run any inline <script> that has no matching nonce or hash, and that includes the one script this guide tells you to put in <head> before anything else. Leave the hash out and the exact script written to run before first paint is the one this policy blocks, so the flash it exists to prevent comes back, together with a securitypolicyviolation on every load.

The hash pins the exact bytes of that one script, text content included, down to the newline the browser keeps immediately after the opening <script> tag. Change a single character inside it, including reindenting it, and the browser computes a different hash and blocks it again; nothing on screen announces this, only the console does. Regenerate it by taking the hash out, reloading with the console open, and copying the value the browser names in the violation it prints: Chromium and Firefox both report the exact hash the script would need in the same message that says the script was blocked. Put that value back into script-src in place of the old one.

A nonce is the usual alternative to a hash, and it does not carry that per-character fragility, but it needs a server that mints a fresh random value on every response and writes it into both the header and a nonce attribute on the tag. A page with no server behind it, which is the premise of this route, has nothing that can do that: a nonce baked once into a static file is not a nonce, it is a fixed string, and it protects nothing that 'unsafe-inline' would not, because anyone reading the page source can copy it onto a script of their own. If your page is rendered by something that can generate a value per request, for example the raw PHP or Laravel path, a nonce is the better fit there, since it survives an edit to the script with nothing to regenerate by hand.

2. Or copy it, for a machine with no network

Skip this section if the two tags above already work for you. It is the offline route, kept deliberately: packages/css/build.mjs throws if the stylesheet contains an absolute URL of any kind, and a test byte-compares the copies in apps/examples/static-html and apps/examples/raw-php against the built package, so this path cannot quietly drift into needing the network it exists to avoid.

FileWhat it isSize
wayne-ui.cssTokens, base, components and the WSU chromeabout 164 kB
wayne-ui-theme.jsTheme switching, the drawer, and the sprite injector. No dependenciesabout 58 kB
icons.svgLucide sprite, subset to what the components useabout 14 kB
fonts/Self-hosted Lato and the metric-matched fallback
marks/The shield, the wordmark and Warrior Strong

Download them into an assets/wayne-ui/ folder:

bash
mkdir -p assets/wayne-ui
cd assets/wayne-ui

BASE=https://wayneui.apps.wayne.edu/v2

curl -O $BASE/wayne-ui.css
curl -O $BASE/wayne-ui-theme.js
curl -O $BASE/icons.svg

# The marks and fonts, which the stylesheet references by relative path.
# warrior-strong-reverse.svg is the one the dark theme uses; miss it and the
# footer mark is blank for anyone who switches to dark.
mkdir -p marks fonts
curl -o marks/wsu-shield.svg $BASE/marks/wsu-shield.svg
curl -o marks/wsu-wordmark.svg $BASE/marks/wsu-wordmark.svg
curl -o marks/warrior-strong.svg $BASE/marks/warrior-strong.svg
curl -o marks/warrior-strong-reverse.svg $BASE/marks/warrior-strong-reverse.svg

# Twelve weight and script combinations of Lato, plus the metric-matched
# fallback, all self-hosted so the page needs no third-party font request.
for font in \
  lato-300-latin lato-300-latin-ext \
  lato-400-latin lato-400-latin-ext \
  lato-400-italic-latin lato-400-italic-latin-ext \
  lato-700-latin lato-700-latin-ext \
  lato-700-italic-latin lato-700-italic-latin-ext \
  lato-900-latin lato-900-latin-ext
do
  curl -o fonts/$font.woff2 $BASE/fonts/$font.woff2
done

If you have npm available, npm pack @waynestate/wayne-ui-css and copy package/dist/ out of the tarball instead. You still commit the result; nothing here needs npm at run time.

Once every file sits next to wayne-ui-theme.js, the icon sprite resolves the same way it does on the CDN: as a sibling of the script, same origin as the page, so the <use> elements below work without the injector doing anything at all.

Which stylesheet

wayne-ui.css is for an application behind AccessID. If the public can reach your page, use wayne-ui-official.css instead, which is the same file with the official wayne.edu masthead and footer compiled in. See the public template below.

3. The document

your-site/
  index.html
  assets/
    wayne-ui/
      wayne-ui.css
      wayne-ui-theme.js
      icons.svg
      fonts/
      marks/

The examples below use the copied, local paths. If you linked the CDN instead, replace assets/wayne-ui/ with https://wayneui.apps.wayne.edu/v2/ throughout; nothing else about the markup changes.

The head has four things in it, and the order matters:

html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Course Registration</title>

<link rel="icon" href="assets/wayne-ui/marks/wsu-wordmark.svg">
<link rel="stylesheet" href="assets/wayne-ui/wayne-ui.css">

<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>

<script type="module" src="assets/wayne-ui/wayne-ui-theme.js"></script>
</head>

The inline script is the only script on the page that is not deferred, and that is deliberate. It reads the stored theme and applies it before the first paint. Without it the page renders light and then flips for anyone who has chosen dark, which is an unpleasant flash for everyone and a real problem for anyone sensitive to sudden luminance changes. An external file, even a blocking one, is a round trip during which the wrong theme is already on screen.

Note the order the script checks things in: the stored choice first, and only when nothing is stored does it fall back to prefers-color-scheme. An unrecognised value in wsu-theme is discarded and falls back to the OS setting rather than straight to light. Theming has the full resolution order.

Do not add maximum-scale or user-scalable=no to the viewport meta. Disabling zoom fails WCAG 1.4.4 and is the most common accessibility defect in a mobile layout.

4. The page

html
<body>

<a class="wsu-skip-link" href="#main">Skip to main content</a>

<div class="wsu-shell wsu-shell--sidebar">

  <header class="wsu-app-bar wsu-app-bar--compact">
    <div class="wsu-app-bar__inner">

      <button class="wsu-btn wsu-btn--icon wsu-app-bar__toggle wsu-app-bar__toggle--nav"
              type="button" data-wsu-drawer-toggle="app-sidebar">
        <span class="wsu-sr-only">Open navigation menu</span>
        <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-menu"/></svg>
      </button>

      <a class="wsu-app-bar__mark" href="https://wayne.edu/">
        <img src="assets/wayne-ui/marks/wsu-shield.svg" alt="Wayne State University"
             width="72" height="65">
      </a>
      <span class="wsu-app-bar__divider" aria-hidden="true"></span>

      <a class="wsu-app-bar__brand" href="/">Course Registration</a>

      <nav class="wsu-app-bar__nav" aria-label="Primary">
        <a class="wsu-app-bar__item" href="/term" aria-current="page">Term</a>
        <a class="wsu-app-bar__item" href="/catalog">Catalog</a>
      </nav>

      <div class="wsu-app-bar__end">
        <label class="wsu-sr-only" for="app-search">Search courses and students</label>
        <form class="wsu-app-bar__search" role="search" action="#" method="get">
          <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-search"/></svg>
          <input class="wsu-app-bar__search-field" id="app-search" name="q" type="search"
                 placeholder="Search courses, students, sections"
                 aria-describedby="app-search-hint" data-wsu-search-hotkey>
          <span class="wsu-sr-only" id="app-search-hint">Press Command or Control plus K to search</span>
          <kbd class="wsu-app-bar__search-hint" aria-hidden="true">&#8984;K</kbd>
        </form>

        <button class="wsu-btn wsu-btn--icon wsu-app-bar__toggle wsu-app-bar__search-toggle"
                type="button" data-wsu-search-toggle="app-search-row">
          <span class="wsu-sr-only">Search this application</span>
          <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-search"/></svg>
        </button>

        <button class="wsu-btn wsu-btn--icon wsu-app-bar__toggle" type="button" data-wsu-theme-toggle>
          <svg class="wsu-icon" aria-hidden="true" data-wsu-theme-icon="light"><use href="assets/wayne-ui/icons.svg#wsu-moon"/></svg>
          <svg class="wsu-icon" aria-hidden="true" data-wsu-theme-icon="dark" hidden><use href="assets/wayne-ui/icons.svg#wsu-sun"/></svg>
        </button>
      </div>
    </div>

    <!-- Revealed by the search toggle below the medium breakpoint, where
         the search field above has no room left next to the logo and the
         drawer trigger. -->
    <div class="wsu-app-bar__search-row" id="app-search-row" hidden>
      <label class="wsu-sr-only" for="app-search-narrow">Search courses and students</label>
      <form class="wsu-app-bar__search" role="search" action="#" method="get">
        <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-search"/></svg>
        <input class="wsu-app-bar__search-field" id="app-search-narrow" name="q" type="search"
               placeholder="Search courses, students, sections">
      </form>
    </div>
  </header>

  <div class="wsu-shell__body">

    <div class="wsu-drawer-backdrop" data-wsu-drawer-backdrop="app-sidebar" hidden></div>

    <nav class="wsu-sidebar" id="app-sidebar" aria-label="Sections" hidden tabindex="-1">
      <div class="wsu-sidebar__inner">
        <button class="wsu-btn wsu-btn--icon wsu-btn--ghost wsu-sidebar__close"
                type="button" data-wsu-drawer-close>
          <span class="wsu-sr-only">Close navigation menu</span>
          <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-x"/></svg>
        </button>

        <div class="wsu-sidebar__primary-nav">
          <ul class="wsu-sidebar__list" aria-label="Primary">
            <li><a class="wsu-sidebar__link" href="/term" aria-current="page">Term</a></li>
            <li><a class="wsu-sidebar__link" href="/catalog">Catalog</a></li>
          </ul>
        </div>

        <div>
          <p class="wsu-sidebar__heading" id="nav-registration">Registration</p>
          <ul class="wsu-sidebar__list" aria-labelledby="nav-registration">
            <li><a class="wsu-sidebar__link" href="/courses" aria-current="page">
              <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-clipboard-list"/></svg>
              Select courses</a></li>
            <li><a class="wsu-sidebar__link" href="/schedule">
              <svg class="wsu-icon" aria-hidden="true"><use href="assets/wayne-ui/icons.svg#wsu-calendar"/></svg>
              My schedule</a></li>
          </ul>
        </div>
      </div>
    </nav>

    <main class="wsu-shell__main" id="main" tabindex="-1">
      <div class="wsu-page-header">
        <div>
          <h1 class="wsu-page-header__title">Select courses</h1>
          <p class="wsu-page-header__subtitle">Winter 2027</p>
        </div>
      </div>

      <p class="wsu-prose">Your content.</p>
    </main>
  </div>

  <footer class="wsu-footer">
    <div class="wsu-footer__inner">
      <div class="wsu-footer__mark" role="img" aria-label="Warrior Strong"></div>
      <ul class="wsu-footer__links">
        <li><a href="https://wayne.edu/">wayne.edu</a></li>
        <li><a href="https://tech.wayne.edu/">Help Desk</a></li>
        <li><a href="https://wayne.edu/accessibility/">Accessibility</a></li>
        <li><a href="https://wayne.edu/policies">Privacy and University Policies</a></li>
      </ul>
      <p class="wsu-footer__meta">&copy; 2026 Wayne State University</p>
    </div>
  </footer>
</div>

</body>
</html>

Six things in there are not decoration:

  • The skip link is the first element in <body>. One that comes after the navigation it exists to skip is decoration.
  • <main> has tabindex="-1". Without it the browser scrolls to #main and leaves focus on <body>, so the next Tab goes back to the top of the page. The skip link then appears to work for a mouse user and does nothing for a keyboard one.
  • The current page is marked with aria-current="page", and the stylesheet styles that attribute. There is no .active class, so the visible state and the announced state cannot disagree.
  • The sidebar carries hidden until it is opened. A drawer that is off screen but still focusable is a set of tab stops on invisible links.
  • The bar's own Term and Catalog links are repeated inside the drawer, under .wsu-sidebar__primary-nav. The stylesheet hides .wsu-app-bar__nav below 768px, and the drawer is the only place left on the page to reach it at that width. Leave the repeat out and the primary navigation is simply gone for anyone on a narrow screen, not folded into a smaller menu.
  • The search field in .wsu-app-bar__end is repeated in #app-search-row, hidden until data-wsu-search-toggle reveals it. The stylesheet drops the first copy at the same width it drops .wsu-app-bar__nav, so the toggle is what keeps search reachable on a narrow screen rather than simply gone with it.

5. What the module does

wayne-ui-theme.js is an ES module with no dependencies. Alongside the icon sprite handling above, it wires these data attributes:

AttributeOnBehavior
data-wsu-theme-togglea buttonSwitches theme and stores the choice
data-wsu-theme-iconan svg inside itlight or dark. The other one is hidden
data-wsu-drawer-togglea buttonValue is the id of the drawer to open
data-wsu-drawer-backdropa divValue is the id of the drawer it closes
data-wsu-drawer-closea button inside the drawerCloses it
data-wsu-search-expanda formGrows the search icon into a field and moves focus there
data-wsu-search-togglea buttonValue is the id of the row it reveals
data-wsu-search-hotkeya search inputFocus on Command or Control plus K

Table sorting needs no attribute at all. Any button with the class wsu-table__sort inside a th is bound automatically, on this path the same way it is on every other one. See the table page for what that button does and the one case where this module leaves a table alone.

The drawer moves focus into the panel on open, traps Tab inside it while it is open, closes on Escape, and puts focus back on the button that opened it. It also closes itself if the viewport crosses 768px while it is still open, because above that width the sidebar docks and the button that would have closed it is gone, and it holds the page behind it still while it is open, so a wheel over the backdrop cannot scroll the content the user is about to return to. None of that is optional behavior you can leave out, which is why it is in the module rather than in your page.

If the sprite ever needs to live somewhere other than next to wayne-ui-theme.js, for example the script loads from the CDN but the sprite is mirrored somewhere else, set data-wsu-sprite on <html> to that URL. It lives on <html> rather than on the <script> tag because document.currentScript is never populated for a module script, and this file is always loaded as one.

The public template

If a prospective student can reach the page, use the official university chrome:

html
<link rel="icon" href="assets/wayne-ui/marks/wsu-shield.svg">
<link rel="stylesheet" href="assets/wayne-ui/wayne-ui-official.css">

Then paste the masthead and footer markup, which is published next to the stylesheet as official-header.html and official-footer.html, above and below your .wsu-app-bar. Drop the shield and the divider from your own bar: with the masthead above it, they are the second and third WSU marks on the page.

The application's own search becomes an expanding field on this template, because the masthead already carries a wayne.edu search box and two identical-looking search fields doing different jobs is confusing. See apps/examples/static-html/public-app.html for the whole page.

WARNING

Do not copy the masthead markup into a template and forget about it. WayneUI 1.x pasted a 2017 snapshot of the wayne.edu header into a Blade file and it was still there nine years later, by which point the official component had renamed its wrapper. Take a fresh copy of official-header.html whenever you update the stylesheet.

Check it before you ship it

  • Tab through the whole page. The skip link comes first, focus is visible on every stop, and nothing invisible takes focus.
  • Narrow the window to 320px. The page itself must not scroll sideways. Tables scroll inside their own box, which is allowed.
  • Zoom to 400%. Same test.
  • Switch to dark and read it again. Most of the color defects found in this system were only visible in one theme.
  • Turn the stylesheet off. The page should still be readable in document order, because the order in the DOM is the reading order.
  • If you linked the CDN, open the network tab and confirm the icon sprite request succeeds. A blocked or failing request is invisible on screen until you look for a blank icon specifically.

Next