Skip to content

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

bash
npm create vite@latest my-app -- --template vue
cd my-app
npm install

The 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

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

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

js
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

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

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

Pass it explicitly if your application is not served from /

That default is a root absolute path, and it is baked into the published package rather than worked out in yours. @waynestate/wayne-ui-vue ships a built bundle, and Vite replaces import.meta.env.BASE_URL with a literal string when it builds, so the value frozen into the bundle is this package's own base and not your application's.

That default is right for a Citadel application, which gets its own subdomain and is served from the root of it: this documentation site is wayneui.apps.wayne.edu/, not a path under something else. If that is where you are deploying, there is nothing to do here.

It is wrong the moment an application is served from a subpath. Then the sprite is requested at /assets/wayne-ui/icons.svg rather than /your-app/assets/wayne-ui/icons.svg. Every icon renders at the right size and completely blank, which reads like a stylesheet problem and is not one. The runtime now warns in the console with the URL it tried, so the next person does not have to guess, but the fix is to pass sprite yourself:

js
app.use(WayneUI, { sprite: `${import.meta.env.BASE_URL}assets/wayne-ui/icons.svg` })

That expression is evaluated in your build, so it picks up the base your own Vite config sets.

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:

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

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>

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

vue
<!-- 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:

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

js
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

bash
npm run build
npm run preview

Nothing 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/*.css on 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