Vue, with no Laravel
A Vite single-page application, or any Vue 3 app with no PHP behind it at all: a dashboard talking to its own API, a tool that lives entirely in the browser. It uses the same @waynestate/wayne-ui-vue components the Inertia guide does, minus the middleware that shares Laravel's chrome data with the page. Here you pass nav, breadcrumbs and the rest as plain props, because there is no Laravel side to hand them to you.
Vue 3.5, Vite 7 or newer.
1. Create the application
npm create vite@latest my-app -- --template vue
cd my-app
npm installThe vue template, not vue-ts. That scaffolds Vue 3.5 on Vite and nothing else. WayneUI adds two dependencies to it and asks for no other tooling: no Tailwind, no PostCSS config, nothing added to vite.config.js.
2. Install WayneUI
npm install @waynestate/wayne-ui-vue @waynestate/wayne-ui-css@waynestate/wayne-ui-vue is the components. @waynestate/wayne-ui-css is the stylesheet and the small runtime that drives it, the same package the static, raw-PHP and Blade paths link with a <link> and a <script> tag; here it arrives as a JavaScript import instead.
3. The stylesheet
One line in src/main.js, before you mount anything:
import '@waynestate/wayne-ui-css'That import is already-built CSS. wayne-ui-css's entry point is tokens through components with no @apply and no Tailwind-only syntax anywhere in it, so Vite bundles it as plain CSS and your own build never needs to know Tailwind exists to get the full result. Building this exact page with only that one import produced a 96.66 kB stylesheet (gzip 31.48 kB), measured from dist/assets/*.css after npm run build: fonts, base, chrome and every component, the entire brand.
The official overlay is an addition, not an alternative
If the public can reach the page, also import the official chrome overlay, after the line above, never in its place:
import '@waynestate/wayne-ui-css'
import '@waynestate/wayne-ui-css/official'/official layers the wayne.edu masthead and footer on top of the base stylesheet. Imported by itself it measured 10.20 kB (gzip 3.45 kB): the masthead and footer adjustments, and nothing else. No tokens, no .wsu-shell, no .wsu-btn. A build carrying only that line renders an unstyled page with a literal "Open navigation menu" button label next to a shield mark at whatever size an unconstrained <img> happens to draw it, because that really is everything the file contains. This is not a hypothetical: it is what this package's own README instructed until a few hours before this guide was written, because the two lines above once read as alternatives rather than as one import required either way with a second layered on top of it.
With both lines imported, the same build measured 106.86 kB, which is the first number plus the second: the base stylesheet, then the masthead and footer rules on top of it. If your own build ever comes out near 10 kB on a page meant to be public, this is the first thing to check.
4. Register the plugin, or import per component
import { createApp } from 'vue'
import WayneUI from '@waynestate/wayne-ui-vue'
import App from './App.vue'
import '@waynestate/wayne-ui-css'
createApp(App)
.use(WayneUI, { sprite: '/assets/wayne-ui/icons.svg' })
.mount('#app')app.use(WayneUI, ...) registers every component globally under a Wsu prefix, so any template can write <WsuButton> with nothing imported. The prefix exists because Footer, Dialog, Input and Select all collide with something, a native element or a component your application already has, and a silent collision with a native element is a confusing thing to debug. Pass prefix: '' to opt out of it.
Importing the named exports per file works exactly the same and tree-shakes, which is what a Vite application should prefer:
import { AppShell, AppBar, Button } from '@waynestate/wayne-ui-vue'Pass sprite either way, but know what it actually buys you. <Icon> and every component that renders one read the sprite location from useSprite(), which defaults to /assets/wayne-ui/icons.svg even with no plugin call at all, so a sprite copied to that exact path (the next step) renders whether or not app.use() ever ran: a same-origin <use href="/assets/wayne-ui/icons.svg#wsu-menu"> resolves on its own, no JavaScript required. What the plugin call adds is data-wsu-sprite on <html>, which matters the moment the sprite stops living at that default, on a CDN or fingerprinted into a build path with a different origin than the page: that is the one case a <use> cannot resolve by itself, and data-wsu-sprite is what @waynestate/wayne-ui-css/theme reads to fetch it and rewrite the reference. Set it now and the sprite can move later without touching a single <Icon>.
Served from a subpath? The default follows your base
The default resolves at runtime rather than being frozen at this package's own build. When the application is served from a subpath, the sprite URL is derived from the module's own address inside your bundle, everything up to its /assets/ segment, so a base: '/your-app/' build requests /your-app/assets/wayne-ui/icons.svg with nothing passed. Measured from a real subpath build before this claim was written: icons paint, no warning. A root deployment, which is what a Citadel application is since each gets its own subdomain, resolves exactly as it always did.
Two honest limits. If your build renames Vite's assets directory, the derivation has nothing to anchor on and falls back to the root absolute default; pass sprite explicitly then, and the runtime's console warning names the URL it tried if you forget. And the resolution only helps the sprite: a root absolute asset path you write yourself, like the mark this guide's own worked page passes as /assets/wayne-ui/marks/wsu-shield.svg, is still an ordinary 404 under a subpath. The fix for those is the expression evaluated in your build:
app.use(WayneUI, { sprite: `${import.meta.env.BASE_URL}assets/wayne-ui/icons.svg` })`${import.meta.env.BASE_URL}assets/wayne-ui/marks/wsu-shield.svg` for the mark, for the same reason.
5. The icon sprite
This is the step that fails with nothing to look at. A missing sprite is not an error: it is a button that is still there, still clickable, with an empty 20 by 20 pixel box where an icon should be.
The sprite is built into @waynestate/wayne-ui-css, not into the Vue package:
node_modules/@waynestate/wayne-ui-css/dist/icons.svg
node_modules/@waynestate/wayne-ui-css/dist/marks/Copy both into public/, at the path useSprite() defaults to and the sprite option above points at:
mkdir -p public/assets/wayne-ui
cp node_modules/@waynestate/wayne-ui-css/dist/icons.svg public/assets/wayne-ui/
cp -r node_modules/@waynestate/wayne-ui-css/dist/marks public/assets/wayne-ui/Vite copies everything under public/ to the root of dist/ unchanged, so this is served at /assets/wayne-ui/icons.svg in both npm run dev and the production build, exactly where the default expects it and where the mark prop on AppBar below points. Automate the copy rather than remembering it by hand: apps/examples/vue-spa in this repository runs a small scripts/copy-assets.mjs before dev and build for exactly this reason, because a copy made once and never rerun goes stale the moment the installed version of wayne-ui-css changes what it subsets.
What it looks like when this step is skipped
Every <Icon> and every icon a component renders internally (AppBar's menu button, a Button's icon slot, the theme toggle) still renders its <svg> and <use> element at its usual size. getComputedStyle reports nothing wrong, and the browser console stays silent, because the request for the missing sprite does not fail the way a missing script or stylesheet would: Vite's own dev and preview servers, like most single-page-application hosts, answer a request for a path that does not exist with index.html rather than a 404. The runtime that fetches and injects the sprite gets a document back, fails to parse it as SVG, and gives up quietly rather than throwing. Nothing on the page says the sprite is missing; the only sign is looking at it and seeing that every icon is blank, which is why this step earns more room here than a two-line copy command looks like it needs.
The one exception is the mark prop on AppBar, an <img> rather than a sprite reference: a missing file there is an ordinary 404 and shows the browser's own broken-image glyph, because an <img> and an SVG <use> fail in unrelated ways.
6. The pre-paint theme script
index.html needs this in <head>, before anything else:
<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>It cannot move into main.js. A module script is deferred, so by the time the bundle runs and mounts the app, the page has already painted light and anyone who chose dark watches it flip: an unpleasant flash for everyone, and a real problem for anyone sensitive to sudden luminance changes.
The stored choice wins first; prefers-color-scheme is only read when wsu-theme holds nothing usable, including a value the script does not recognize. Theming has the full resolution order.
7. A worked page
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
import {
AppShell, AppBar, Sidebar, SidebarNav, SkipLink,
PageHeader, Footer, Card, Button, Tabs, Icon,
} from '@waynestate/wayne-ui-vue'
const query = ref('')
const sidebar = [
{ heading: 'Registration', items: [
{ label: 'Select courses', href: '#', icon: 'clipboard-list', current: true },
{ label: 'My schedule', href: '#', icon: 'calendar' },
] },
]
const tab = ref('schedule')
const tabs = [
{ id: 'schedule', label: 'Schedule' },
{ id: 'seats', label: 'Seats' },
]
const count = ref(0)
</script>
<template>
<SkipLink />
<AppShell>
<template #bar>
<AppBar
v-model:search-query="query"
variant="compact"
title="Course Registration"
mark="/assets/wayne-ui/marks/wsu-shield.svg"
search
search-label="Search courses and students"
>
<template #nav>
<a class="wsu-app-bar__item" href="#" aria-current="page">Term</a>
<a class="wsu-app-bar__item" href="#">Catalog</a>
</template>
</AppBar>
</template>
<template #sidebar>
<Sidebar label="Sections">
<template #primary-nav>
<li><a class="wsu-sidebar__link" href="#" aria-current="page">Term</a></li>
<li><a class="wsu-sidebar__link" href="#">Catalog</a></li>
</template>
<SidebarNav
v-for="group in sidebar"
:key="group.heading"
:heading="group.heading"
:items="group.items"
/>
</Sidebar>
</template>
<template #footer>
<Footer :links="[
{ label: 'wayne.edu', href: 'https://wayne.edu/' },
{ label: 'Help Desk', href: 'https://tech.wayne.edu/' },
]" />
</template>
<PageHeader title="Select courses" subtitle="Winter 2027" />
<Card title="Request an override">
<p class="wsu-prose">Count: {{ count }}</p>
<Button variant="primary" @click="count++"><Icon name="plus" />Add one</Button>
</Card>
<Tabs class="course-tabs" :tabs="tabs" v-model="tab" label="Course details">
<template #schedule><p class="wsu-prose">Monday and Wednesday, 10:30 to 11:45.</p></template>
<template #seats><p class="wsu-prose">12 of 40 seats remain.</p></template>
</Tabs>
</AppShell>
</template>
<style scoped>
/**
* Page layout only. Neither selector reaches a wsu- class, so nothing here
* restyles a WayneUI component: just the margin around this page's own tabs.
*/
.course-tabs {
margin-block-start: var(--wsu-space-6);
max-inline-size: 34rem;
}
</style>Note what is not here. There is no listener that opens or closes the sidebar and no code that flips the theme: AppBar's menu button carries data-wsu-drawer-toggle="app-sidebar", Sidebar defaults its own id to match it, and the theme button carries data-wsu-theme-toggle. Both work the moment the page mounts. Where that comes from is the next step.
.wsu-app-bar__item and .wsu-sidebar__link on plain <a> tags, rather than a nav prop, is deliberate: a real router would put a <RouterLink> there instead, the way the Inertia guide does with Inertia's Link, and RouterLink sets aria-current="page" on the active one for you, which is what the stylesheet keys its highlight off. Nothing in this page needs a router to work, so plain anchors stand in for one.
8. The public template
Swap the bar's own shield for the real one and add the university footer:
<script setup>
import {
AppShell, AppBar, Sidebar, SidebarNav, SkipLink, PageHeader,
OfficialHeader, OfficialFooter,
} from '@waynestate/wayne-ui-vue'
</script>
<template>
<SkipLink />
<AppShell>
<template #masthead>
<OfficialHeader />
</template>
<template #bar>
<AppBar
variant="default"
title="Course Catalog"
search
search-variant="expandable"
search-label="Search the course catalog"
/>
</template>
<template #footer>
<OfficialFooter />
</template>
<PageHeader title="Computer Science" subtitle="Winter 2027" />
</AppShell>
</template>Three changes carry the whole distinction. variant="default" instead of compact, and no mark: AppBar only draws its own shield in the compact bar, because everywhere else a masthead has already established the university's identity, and a second shield directly under the first one reads as a mistake rather than as emphasis. search-variant="expandable" instead of the always-open field, because the official masthead already carries a wayne.edu search box, and two identical-looking fields doing different jobs side by side is a confusing thing to put in front of someone. And OfficialHeader and OfficialFooter in place of your own footer, which render @waynestate/wsuheader and @waynestate/wsufooter themselves. They are consumed as dependencies, never copied into a component: WayneUI 1.x pasted a 2017 snapshot of the masthead into a Blade file and it was still there nine years later, by which point the official component had renamed its own wrapper class.
The stylesheet side of this distinction is step 3 above: both the base import and /official have to be present for this to render correctly, and the byte counts there are how to catch it if one of them silently is not.
9. Where the behavior comes from
Theme switching, the drawer, the expanding search field and the Cmd/Ctrl+K shortcut all come from @waynestate/wayne-ui-css/theme, the same runtime the static, raw-PHP and Blade paths load as a <script type="module"> tag. AppShell imports it as source and calls its init() once the document has finished loading, for markup Vue has just mounted. A drawer opens, traps focus inside it, closes on Escape and returns focus to the button that opened it identically on all four paths, because it is the same code running on each one, not a Vue reimplementation of it.
import { useTheme } from '@waynestate/wayne-ui-vue'
const { theme, isDark, toggle } = useTheme()is there for a page that needs to read or drive the theme itself. The worked page above never calls it: AppBar's own toggle button already does the whole job, wired the same way the drawer button is.
10. Build it
npm run build
npm run previewNothing about WayneUI changes what a Vite production build does. vite build minifies and fingerprints this stylesheet the same as any other CSS it bundles, and copies public/ to the root of dist/ unchanged, sprite and marks included. The byte counts in step 3 came from that build, not from npm run dev, where Vite serves CSS unminified over HMR and the numbers do not mean anything; measure after a real build.
vite build prints a warning about new URL('icons.svg', import.meta.url) from inside wayne-ui-theme.js. It is harmless and specific to this package: that expression is how the runtime normally finds a sprite sitting next to its own script tag, worked out from the script's bundled URL, and the guess is wrong once Vite folds the module into its own fingerprinted chunk. It never actually runs here: data-wsu-sprite, set by the plugin in step 4, always answers first. The warning fires because Vite's bundler can see the expression at build time, not because anything at runtime reaches it.
Check it before you ship it
- Copy the sprite and marks (step 5) before the first
npm run build, not after finding a blank icon in it. Nothing fails loudly if you skip it. - Confirm the built stylesheet is the full one:
ls -la dist/assets/*.csson a publicly accessible page should read in the neighborhood of 100 kB, not 10. - Tab through the page. The skip link is first, the drawer traps focus while open, Escape closes it, and focus returns to the button that opened it.
- Narrow the window to 375px and open the drawer.
- Switch to dark and read the page again. Most of the color defects this system has shipped were only visible in one theme.
Next
- Components for every component and its props
- Laravel, Inertia and Vue if a Laravel backend joins later. The components do not change, only where the data comes from
- Theming for the resolution order and the shadcn-vue and daisyUI adapters
- Accessibility for what the gates check