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

Matte

Flat and sharp-cornered — no radius, no shadow, no gloss. Hairline rules and mono micro-labels do the work borders usually do.

paper

Paper

The neutral one. Start here if unsure: soft corners, light shadows, generous spacing, and nothing that fights the content.

sprout

Sprout

Warm and fully rounded — leaf green on white, pill buttons, and a round display face. For school, community and family sites.

the import names the style
// 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 look

There is no default

A root import resolves to nothing on purpose. Picking a style by accident — and shipping it — is the one mistake this layout exists to prevent.

The fast path

Four commands from nothing to a running app:

terminal
bunx sv create myapp --template minimal --types ts --install bun
cd myapp
bun add -d tailwindcss @tailwindcss/vite
bun add @nqmcreative/ui @lucide/svelte

Then let the CLI do the wiring — it writes the entry app.css, patches the document <head>, and makes sure that CSS is loaded once:

terminal
bunx nqm-ui init --style matte

It 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

terminal
bun add @nqmcreative/ui @lucide/svelte

Icons come from @lucide/svelte

The components import their glyphs — the chevron on a 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

vite.config.ts
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

src/app.css — matte
@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.

src/app.css — paper
@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

Without them components render unstyled, with no error anywhere. Tailwind v4 skips node_modules, so it never sees the class names that live inside the package. Point it at the style you actually import — scanning the other styles only slows the build — and at 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

src/routes/+layout.svelte
<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.

src/app.html
<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

The components ship as .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

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

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

vite.config.ts
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()]
});
src/main.ts
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.

terminal
npm i @nqmcreative/ui @lucide/svelte
npm i -D svelte @sveltejs/vite-plugin-svelte tailwindcss @tailwindcss/vite
npx nqm-ui init --style matte
vite.config.js
import { 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.

resources/css/app.css — matte
@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

Blade templates and Svelte islands sit outside 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.

resources/views/app.blade.php
<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>
resources/js/app.js
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 });
resources/js/App.svelte
<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.

+page.svelte
<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.

either way
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.

the whole migration
// 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

terminal
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.svelte

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

terminal
npm install @nqmcreative/ui@latest

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

terminal
bunx nqm-ui update --style matte

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

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

esc
Accordion Wrapper that stacks collapsible items.
AccordionItem A single collapsible row, built on <details> so it works without JS.
Alert Inline message in any tone, optionally dismissible.
Avatar Image or initials, circular or squared, in five sizes.
AvatarGroup Overlapping row of avatars, each ringed in the background.
Badge Pill label for status and counts. Solid, soft or outline.
Breadcrumb Trail of links to the current page.
Button Five variants and four sizes, with a loading state and an href mode.
Calendar Month grid with keyboard navigation, bounds and disabled days.
Card Surface with an optional header and footer, in three variants.
Checkbox Single checkbox with a label, description and indeterminate state.
CheckboxGroup A set of checkboxes bound to a string array, with an optional cap.
ColorInput Hex field paired with the native swatch and fixed choices.
Combobox Single-select with a text filter and keyboard navigation.
CommandPalette Cmd+K launcher with grouping, shortcuts and hidden keywords.
ConfirmDialog Small dialog that stays busy until an async confirm settles.
ContextMenu Right-click menu placed at the pointer, flipped near the edges.
CTASection Closing call to action, in three variants.
CurrencyInput Amount field that groups on blur and binds a number.
DatePicker Text field plus a calendar popover. Value is a YYYY-MM-DD string.
Divider Rule, horizontal or vertical, with an optional caption.
Drawer Panel that slides in from any edge, on the native dialog top layer.
Dropdown Menu anchored to a trigger, with focus trap and viewport flipping.
Dropzone Drag-and-drop file input with type, size and count validation.
EmptyState Placeholder for an empty list, with a glyph and an action slot.
FeatureCard One feature: glyph, heading, copy. Optionally a whole link.
FeatureGrid Section wrapper that lays feature cards out in 2, 3 or 4 columns.
Field Wraps any control with a label, hint and error message.
FileInput One-line file picker with type, size and count validation.
Footer Site footer with link columns and a bottom bar.
HeroSection Page opener — eyebrow, headline, copy, actions, optional media column.
Input Text field with prefix and suffix slots, sizes and an invalid state.
InputAddon Fixed label joined to an input inside an InputGroup.
InputGroup Joins an input to a button or addon with a single shared border.
Kbd Keyboard key.
Label Form label with an optional required marker.
Link Anchor in any tone, with an external variant.
LogoCloud Row of client or partner marks, dimmed until hovered.
MenuItem Row inside a Dropdown or ContextMenu, with a shortcut hint.
MenuSeparator Rule between menu groups, with an optional caption.
Modal Centred dialog on the native top layer, with a footer slot.
MultiSelect Multi-select with chips, grouping and a selection cap.
Navbar Top bar that collapses into a drawer below the md breakpoint.
Pagination Page numbers that collapse to an ellipsis around the current one.
PinInput One box per character, with paste spreading and arrow keys.
Popover Anchored panel that opens on click or hover.
PricingCard One plan: price, period, ticked feature list, optional featured styling.
Progress Determinate or indeterminate bar, in three sizes.
Radio Single radio bound to a shared group value.
RadioGroup A set of radios, optionally rendered as selectable cards.
SearchInput Search field with a clear button and a debounced callback.
SegmentedControl Exclusive choice as one joined row, with arrow-key movement.
Select Native select with a chevron and data-driven options.
Sidebar Nested, collapsible navigation rail for an app shell.
Skeleton Pulsing placeholder in text, block or circle form.
Slider Range input with ticks and a printed value.
Spinner Rotating ring that inherits the surrounding colour.
Stat Figure with a label, delta and trend arrow.
StatsBand A row of figures, built on Stat.
Steps Progress through a flow, horizontal or vertical, with a failed state.
Switch On/off toggle with a label and description.
Table Data-driven table with sorting, row selection and a cell snippet.
Tabs Underline, pill or segmented tabs bound to a value.
TagsInput Free-text tokens committed on Enter, deduplicated and capped.
Testimonial A quote with an attributed author.
Textarea Multi-line field that can grow with its content.
ThemeToggle Light, dark and system, remembered in localStorage.
TimeInput HH:MM field with steppers, bounds and a minute step.
Toaster Renders the toast queue. Mount once, near the root.
Tooltip CSS-only label on hover and focus, in four placements.