Light and dark themes · 9 built-in locales · accessible primitives on Reka UI
Installation · Usage · Showcase · Components · Theming · Component catalog · Demo app
Why UI Vintage
UI Vintage is a Nuxt 4 module and component library for dashboards, forms, landing pages, overlays, and blogs that should look and feel like one product. It gives you low-level primitives (buttons, inputs, selects, dialogs, tabs) and complete building blocks (dashboard and landing shells, stat cards, data tables, a full blog) that share one set of theme tokens and one motion vocabulary.
- Source-published. The package ships its Vue and TypeScript source. Your Nuxt app compiles it together with your own code, so there is no separate build artifact and every Nuxt integration (
NuxtImg,#components, module hooks) keeps working. - Explicit imports. Every component lives behind its own subpath, such as
@brumaombra/ui-vintage/button. Nothing is auto-registered, so your dependencies stay obvious and tree-shakeable. - Accessible by default. Interactive primitives are built on Reka UI, with keyboard support, focus management, and ARIA wiring included.
- Motion that respects people. Springs are pure CSS
linear()easings, and every animation collapses when the user prefers reduced motion. - More than primitives. Promise-based dialogs and toasts, dashboard and landing shells, data tables, a complete Nuxt Content blog, 9 locales, and an installable app with an offline page.
- Ready for AI coding agents. A generated component catalog (
COMPONENTS.md) and agent instructions (AGENTS.md) ship inside the package.
Installation
Install the package with its peer integrations in an existing Nuxt 4 app. The stylesheet is built with Tailwind CSS v4, the components use Vue I18n for their labels, and icons come from Hugeicons:
npm install @brumaombra/ui-vintage @nuxt/image @nuxtjs/i18n tailwindcss @tailwindcss/vite @hugeicons/core-free-icons @hugeicons/vue
Register the module, Nuxt I18n, and the Tailwind Vite plugin in nuxt.config:
import tailwindcss from '@tailwindcss/vite';
export default defineNuxtConfig({
modules: [
'@brumaombra/ui-vintage',
'@nuxtjs/i18n'
],
vite: {
plugins: [
tailwindcss()
]
},
i18n: {
defaultLocale: 'en',
locales: [{ code: 'en', language: 'en-US' }]
}
});
That's it: the module injects the shared stylesheet, adds the source to Nuxt transpilation, installs @nuxt/image when your app has not registered it, and merges the library messages into your i18n setup. The components call useI18n(), so an i18n setup is required even for a single-language app. Using the blog or content components? Install @nuxt/content too.
Usage
Use a component
Import components from their public subpath:
<script setup>
import { Button } from '@brumaombra/ui-vintage/button';
import { SingleValueCard } from '@brumaombra/ui-vintage/single-value-card';
import Money03Icon from '@hugeicons/core-free-icons/Money03Icon';
</script>
<template>
<SingleValueCard label="Revenue" :value="48290" :icon="Money03Icon" :trend="12.4" trend-label="vs last month"
:format-options="{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }" />
<Button variant="primary">Save changes</Button>
</template>
Ask, confirm, and notify
Dialogs, toasts, and the busy overlay mount themselves on first use, so you never place them in a layout:
import { showConfirmDialog } from '@brumaombra/ui-vintage/confirm-dialog';
import { showMessageToast } from '@brumaombra/ui-vintage/message-toast';
import { setBusy } from '@brumaombra/ui-vintage/busy-indicator';
// Resolves true or false with the user's choice
const confirmed = await showConfirmDialog({
title: 'Delete project?',
message: 'This action cannot be undone.',
confirmButtonType: 'red'
});
if (confirmed) {
// Keep the shared loading overlay visible while the request runs
setBusy(true, { label: 'Deleting project...' });
await deleteProject();
setBusy(false);
showMessageToast({ type: 'success', title: 'Project deleted', message: 'Everything was removed.' });
}
Showcase
Every screenshot comes from the demo app, and each one follows your GitHub theme (light or dark).
Buttons |
Forms |
Data table |
Stats and numbers |
Toasts |
Confirm dialog |
Blog home |
Blog post |
Components
Every entry point, grouped as in the catalog. Each name is also its import subpath, for example @brumaombra/ui-vintage/data-table.
| Category | Entry points |
|---|---|
| Actions | button, dropdown-menu, kbd, load-more-button, text-link, toggle-group |
| Forms | calendar, checkbox, combobox, date-picker, date-time-picker, field, file-dropzone, input, label, native-select, number-field, pin-input, radio-group, select, slider, slider-form-component, switch, switch-form-component, tags-input, textarea, time-picker |
| Data display | animated-number, avatar, badge, card, card-grid, chip, data-list, data-table, info-card, line-chart, progress, progress-component, scroll-area, separator, single-value-card, skeleton, stat-strip, table |
| Navigation | accordion, breadcrumb, collapsible, pagination, sidebar, stepper, tabs |
| Overlays | alert-dialog, command, dialog, hover-card, popover, sheet, tooltip |
| Feedback | alert, busy, busy-indicator, confirm-dialog, cookie-consent, empty-state-card, loading-state-card, message-dialog, message-toast, spinner |
| Layouts and app shell | background-grid, dashboard-shell, error-page, landing, landing-content, landing-footer, landing-navbar, landing-shell, language-flag, language-selector, page-header, theme-selector |
| Content and blog | blog, content |
| Utilities and types | common-types, utils |
Component catalog
COMPONENTS.md documents every entry point: its import line, props and defaults, events, slots, helpers, and examples. It is generated from the source and ships inside the package, so your app can always read the version it installed at node_modules/@brumaombra/ui-vintage/COMPONENTS.md.
Using UI Vintage with AI coding agents
The package ships AGENTS.md, a short set of instructions that teaches coding agents to reuse UI Vintage: read the catalog before writing UI, pick the right component, follow the library conventions, and check the result.
Point your agent to it from your app's own instructions file, so it always reads the version you installed:
-
Codex, Cursor, GitHub Copilot, Gemini, and other tools that read
AGENTS.md: add this line to theAGENTS.mdat the root of your app:Before writing UI, read and follow node_modules/@brumaombra/ui-vintage/AGENTS.md. -
Claude Code: import it in your app's
CLAUDE.md:@node_modules/@brumaombra/ui-vintage/AGENTS.md
Both the instructions and the catalog update with the package, so you never need to copy them again.
Theming
Tokens and dark mode
The stylesheet defines semantic tokens (background, foreground, card, primary, muted, border, and more) for both themes. Dark mode follows the .dark class on <html>; ThemeSelector sets it for you, with light, dark, and automatic modes. Extend the existing CSS custom properties in your app stylesheet instead of creating a second token system.
The whole page uses one font, the --font-mono token (the system monospace by default), so font-mono elements match the text. Set your own once, in any stylesheet of the app:
:root {
--font-mono: 'JetBrains Mono', monospace;
}
Brand color
The brand color (buttons, focus rings, selected states, the sidebar) comes from six tokens, so an app can replace the default orange in its own stylesheet, for both themes:
:root {
--uv-primary-light: #c62d3a;
--uv-primary-hover-light: #ad2230;
--uv-primary-foreground-light: #ffffff; /* Text on the brand color */
--uv-primary-dark: #ef5b67;
--uv-primary-hover-dark: #f47d86;
--uv-primary-foreground-dark: #260609;
}
The library's defaults have zero specificity, so these win whatever the order of the style sheets. To change the color at runtime (a theme per customer, per mode, per selected item), set the same tokens under an attribute of <html>, such as :root[data-brand='teal'] { … }, and switch the attribute. Keep the foreground readable on the brand color in both themes.
The standalone: variant styles the installed app (opened from the home screen, or from the Play Store as a Trusted Web Activity) through @media (display-mode: standalone), for example standalone:hidden on a footer that only belongs in the browser, or standalone:h-dvh on a screen that must fit without scrolling. It needs no script, so the page never flashes the browser look first.
The module injects the stylesheet automatically. Import it explicitly only when you need to control the loading order (the import gets the same stylesheet the module injects, with the classes of the components the app uses):
import '@brumaombra/ui-vintage/style.css';
LandingNavbar, LandingFooter, and DashboardShell accept app-logo and app-logo-dark, so your logo can follow the theme too.
Motion
Every component uses the same small vocabulary, and your app can reuse it:
- Easings:
ease-spring,ease-bounce,ease-out-expo, andease-snappy. - Surfaces:
uv-floating-motion(popovers, menus, tooltips),uv-modal-motion,uv-overlay-motion,uv-collapsible-motion, anduv-field(focus ring and invalid states for inputs). - Animations:
animate-uv-pop,animate-uv-fade-up,animate-uv-shake,animate-uv-shimmer,animate-uv-float,animate-uv-ping-soft,animate-uv-word-in,animate-uv-grow-x, andanimate-uv-draw. - Elevation:
shadow-elevated-smthroughshadow-elevated-xl.
All durations collapse when the user prefers reduced motion. Tailwind v4 animates scale, rotate, and translate as separate properties, so name them (not transform) in custom transitions.
Localization
Library strings live under the uiVintage namespace and are available in English, Italian, French, Spanish, German, Portuguese, Chinese, Japanese, and Russian. With @nuxtjs/i18n or Vue I18n configured, the runtime plugin merges them into your existing composer without replacing your messages.
Installable app
Set uiVintage: { pwa: { name: 'Acme' } } to make the app installable, with a service worker, an offline page, a manifest per language, push notifications, and the Android app (Trusted Web Activity), with no extra dependencies. See docs/pwa.md for every option.
How it works
When the module is registered, it:
- Injects
src/styles.cssinto the app once, with Tailwind scanning only the library files the app uses (see below). - Adds the published
src/directory to Nuxt transpilation. - Installs
@nuxt/imagewhen the app has not registered it. - Registers the library i18n plugin, which merges the locale messages when Vue I18n is present.
- On iPhone and iPad, adds
maximum-scale=1to the viewport, so Safari doesn't zoom the page in when a field is tapped (people can still pinch to zoom). Fields keep their size on every device. - With
pwaset, writes the service worker and the offline page as static files of the build, and registers the worker in production.
Only the classes the app uses
On every build, the module follows the app's @brumaombra/ui-vintage/... imports through the library and tells Tailwind to scan only the files it reached, which cuts the CSS by 12% to 59% on the apps that use it. Only literal imports from a public subpath are found; set uiVintage: { scanAllComponents: true } to scan the whole library (for example, while debugging a missing class). Files under src/components/ are implementation details and may change between versions.
Demo app
demo-app/ is a documentation-style Nuxt 4 app that shows every component with a live preview and a copyable code tab, a live mini-app on the overview page, a complete blog built with Nuxt Content, and a command palette (Ctrl/⌘ + K) to jump to any section. It imports the package through file:.., so it runs the same source that is published to npm.
It needs Node.js 24.21 or newer. Nuxt Content uses the built-in node:sqlite module instead of a native SQLite build.
# Install and start the demo
npm --prefix demo-app install
npm --prefix demo-app run dev
# Production build
npm --prefix demo-app run build
Development
There is no build step: the published files are module.mjs, src/, COMPONENTS.md, and AGENTS.md. Before opening a pull request, run the checks the release workflow runs:
# Type-check the published TypeScript and Vue source
npm run typecheck
# Run the tests (unit tests in Node, component tests inside the demo Nuxt app)
npm test
# Regenerate the component catalog after changing a public API
npm run docs:components
# Fail when COMPONENTS.md is out of date
npm run docs:components:check
New entry points need an export in package.json and a description and category in scripts/components-meta.mjs; the catalog generator fails until they have one. The release process is described in .claude/skills/publish-npm/SKILL.md.
Requirements
- Nuxt 4 and Vue 3.5 or newer.
- Tailwind CSS v4 with
@tailwindcss/viteregistered invite.plugins. @hugeicons/core-free-icons4.3 or newer and@hugeicons/vue1 (the icons you pass to:iconprops, and the ones the components render). Import each icon from its own module, as in the example above: the package barrel makes the build parse the whole icon set.@nuxtjs/i18n10 (the components calluseI18n(), and every link rendersNuxtLinkLocale).@nuxt/image2 for image components (the module installs it when needed).@nuxt/content3, only for theblogandcontentcomponents.
Troubleshooting
- Components can't resolve Nuxt imports: check that the app runs Nuxt 4 and that
@brumaombra/ui-vintageis registered innuxt.config. - Styles are missing: register the module once, add
tailwindcss()from@tailwindcss/vitetovite.plugins, and restart the dev server after changingnuxt.config. NuxtImgis unavailable: install@nuxt/image, or let the module install it during setup.useI18nerrors, missing translations, orNuxtLinkLocalefailing to resolve: register@nuxtjs/i18n; the components need its composer and link component, and the library messages are merged into it.- An import fails: use the public subpath from
package.json, such as@brumaombra/ui-vintage/button, never an internalsrc/path.
License
MIT © Mauro Brambilla