Installation
Published on npm as @nqmcreative/ui. One package, one
install — then you pick a style, and that choice is what every import names.
First, pick a style
Every style ships the same 70 components with the same props and the same behaviour. What changes is the look — and nothing else.
Matte
Flat and sharp-cornered — no radius, no shadow, no gloss. Hairline rules and mono micro-labels do the work borders usually do.
Paper
The neutral one. Start here if unsure: soft corners, light shadows, generous spacing, and nothing that fights the content.
Sprout
Warm and fully rounded — leaf green on white, pill buttons, and a round display face. For school, community and family sites.
// there is no root export — a style has to be named
import { Button } from '@nqmcreative/ui'; // ✗ nothing here
import { Button } from '@nqmcreative/ui/matte'; // ✓
import { Button } from '@nqmcreative/ui/paper'; // ✓ same props, different lookThere is no default
The fast path
Four commands from nothing to a running app:
bunx sv create myapp --template minimal --types ts --install bun
cd myapp
bun add -d tailwindcss @tailwindcss/vite
bun add @nqmcreative/ui @lucide/svelteThen let the CLI do the wiring — it writes the entry app.css, patches the document <head>, and makes sure that CSS is
loaded once:
bunx nqm-ui init --style matteIt recognises SvelteKit, a plain Vite app and Laravel, and wires whichever set of files that one
has. It will not rewrite your Vite config — it prints the two lines to paste. Every write is
idempotent, so running it twice changes nothing. Add --dry-run to see what it would touch.
Or by hand
1. Install
bun add @nqmcreative/ui @lucide/svelteIcons come from @lucide/svelte
Select, the
clear button on a SearchInput, the tick in a Checkbox — from @lucide/svelte, so
it is a required peer dependency and ships in the install line above. Skip it and the build
fails where a component imports its icon; there is no silent fallback. Every icon prop takes a Snippet, so you
pass the same Lucide set into your own components too.2. The Tailwind plugin
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
// tailwindcss() must come before sveltekit()
plugins: [tailwindcss(), sveltekit()]
});3. The entry CSS
@import 'tailwindcss';
@import '@nqmcreative/ui/matte/theme.css';
@import '@nqmcreative/ui/matte/fonts.css';
/* Tailwind v4 skips node_modules — point it at the style's folder and the
shared core, so the class names used inside the components are generated.
Tone colours live in core, so both lines are needed. */
@source '../node_modules/@nqmcreative/ui/dist/styles/matte';
@source '../node_modules/@nqmcreative/ui/dist/core';Import exactly one style's tokens. They all define the same variable names, so two at once would fight over every colour.
@import 'tailwindcss';
@import '@nqmcreative/ui/paper/theme.css';
@import '@nqmcreative/ui/paper/fonts.css';
/* Same idea, pointed at the other style's folder — plus the shared core. */
@source '../node_modules/@nqmcreative/ui/dist/styles/paper';
@source '../node_modules/@nqmcreative/ui/dist/core';Both @source lines are not optional
core, where the shared tone colours live. Adjust the path to
wherever node_modules sits relative to that CSS file.4. Import the CSS once
<script lang="ts">
import '../app.css';
import { Toaster } from '@nqmcreative/ui/matte';
let { children } = $props();
</script>
{@render children()}
<Toaster position="bottom-right" />5. Fonts and theme, before first paint
Every style ships a fonts.css, so the preconnect is
worth having whichever you pick — Work Sans sets running text in all three, and the headings are
Sora in matte, Plus Jakarta Sans in paper, Fredoka in sprout. Skip the import and the tokens
fall back to the system stack with no error anywhere.
Both go in src/app.html, above %sveltekit.head%. Plain HTML rather than <svelte:head>, because Svelte parses a bare crossorigin attribute as boolean true and svelte-check rejects it.
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<script>
const saved = localStorage.getItem('nqm-theme');
if (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);
</script>Outside SvelteKit
Nothing in the library imports $app/*, $env/* or @sveltejs/kit. The peer dependencies are svelte ^5 and @lucide/svelte, and every DOM call sits inside
an action or an effect — so a plain Vite app works, and so does Laravel with Vite as its front
end. nqm-ui init recognises all three and wires the files
that project shape actually has.
One extra dependency out here
.svelte source, published under a svelte export condition. @sveltejs/vite-plugin-svelte is what resolves that condition and compiles
them. SvelteKit brings it along; everywhere else it is an install.Svelte + Vite
npm create vite@latest myapp -- --template svelte-ts
cd myapp
npm i @nqmcreative/ui @lucide/svelte
npm i -D tailwindcss @tailwindcss/vite
npx nqm-ui init --style matteinit writes src/app.css, patches the <head> in index.html, and puts the CSS import at the top of src/main.ts. The Vite config is the one thing it
prints rather than rewrites.
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
// tailwindcss() must come before svelte()
plugins: [tailwindcss(), svelte()]
});import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
export default mount(App, { target: document.getElementById('app') });Laravel
Vite is already how Laravel builds its front end, so Svelte and this package are the only additions. Blade renders the shell; Svelte takes the node it is handed.
npm i @nqmcreative/ui @lucide/svelte
npm i -D svelte @sveltejs/vite-plugin-svelte tailwindcss @tailwindcss/vite
npx nqm-ui init --style matteimport { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
tailwindcss(),
svelte()
]
});The entry CSS is resources/css/app.css, two levels
below the project root — so @source walks two up rather than one.
@import 'tailwindcss';
@import '@nqmcreative/ui/matte/theme.css';
@import '@nqmcreative/ui/matte/fonts.css';
/* Two levels up from resources/css to the project root. */
@source '../../node_modules/@nqmcreative/ui/dist/styles/matte';
@source '../../node_modules/@nqmcreative/ui/dist/core';
/* Your own markup, which lives outside this folder. */
@source '../js';
@source '../views';Point Tailwind at your own markup too
resources/css, so the two @source lines below the package's are not optional either — without
them the classes you write yourself go missing exactly the way the package's would.Nothing imports that CSS from JavaScript: the @vite directive hands the path to Vite, and the <head> it sits in is the one init patches with the preconnect and the no-flash theme
script.
<head>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<script>
const saved = localStorage.getItem('nqm-theme');
if (saved === 'dark' || saved === 'light') document.documentElement.classList.add(saved);
</script>
</head>import { mount } from 'svelte';
import App from './App.svelte';
// One island per view, or one root for the whole page — Blade renders the
// shell, Svelte takes the node it is given.
const target = document.getElementById('app');
if (target) mount(App, { target });<script>
import { Button, Field, Input } from '@nqmcreative/ui/matte';
let email = $state('');
</script>
<Field label="Work email">
<Input bind:value={email} placeholder="you@example.com" />
</Field>
<Button tone="accent">Get started</Button>Using a component
The style barrel is the usual way in — one import line for the whole app.
<script lang="ts">
import { Button, Field, Input } from '@nqmcreative/ui/matte';
let email = $state('');
</script>
<Field label="Work email" hint="We only email about releases.">
<Input bind:value={email} placeholder="you@example.com" />
</Field>
<Button tone="accent">Get started</Button>Each component also has its own subpath: the style, then the file name in kebab-case, so AvatarGroup is @nqmcreative/ui/matte/avatar-group.
import Button from '@nqmcreative/ui/matte/button';
import Field from '@nqmcreative/ui/matte/field';
import Input from '@nqmcreative/ui/matte/input';Both produce the same bundle — measured on one app, barrel and subpaths came out byte-identical, because the barrel tree-shakes. Subpaths are about being explicit, and they cut the module graph the bundler walks.
Changing your mind later is a find-and-replace: the word after @nqmcreative/ui/ is the only thing that differs.
// one project, one style — swap the word and the whole app follows
import { Button, Card, Modal } from '@nqmcreative/ui/matte';
import { Button, Card, Modal } from '@nqmcreative/ui/paper';
import { Button, Card, Modal } from '@nqmcreative/ui/sprout';The CLI
bunx nqm-ui list forms # every component in a category
bunx nqm-ui info date-picker # its import line in every style
bunx nqm-ui add button badge --style matte
bunx nqm-ui add button --style paper --to src/routes/+page.svelteadd is a convenience, not an installer: after bun add the whole library is already there, and a component
pulls in whatever it renders internally. It writes the import line and tells you what comes along.
Updating
Nothing was copied into your project: add printed an
import, and the components themselves live in node_modules. So upgrading the package is the whole
job — every Button in your app is the new Button on the next build.
npm install @nqmcreative/ui@latestThen rewrite the wiring
nqm-ui update is init under the name you reach for afterwards. It
overwrites the lines it owns in src/app.css — the tailwindcss import, this style's theme.css and fonts.css, and the @source line — and leaves everything below them alone,
your own @theme overrides included.
bunx nqm-ui update --style matteRun it after every upgrade, not just when something looks wrong. If a release changes which
files a style imports, or where @source has to point, the
old lines would sit there resolving to nothing and your components would come up unstyled — which
reads like a broken install rather than a stale config.
Leave --style off and it asks, listing the three and taking
a number or a name.
bunx nqm-ui update
which style?
1) matte Flat and sharp-cornered…
2) paper The neutral one…
3) sprout Warm and fully rounded…
1-3, or a name [1]:Restart the dev server afterwards so Tailwind rescans, and delete node_modules/.vite if class names still look like the old
release.
What an upgrade can change
Looks ride along without asking: a release that tightens a style's control scale moves every button in your app on the next build. Prop names, types and behaviour are the contract and only move on a major. If your layout depends on a control being an exact height, pin the version and read the release notes before moving.