Form inputs

Input takes any type, so an email or a URL field is a prop away and not a component. The eight below exist because they each add behaviour a type cannot: a second value, a keyboard rule, a format that has to survive a round trip.

Two shapes

Four of them wrap Input and put something in its prefix or suffix slot, so a change to the field's border, height or focus ring reaches them for free. The other four draw their own box, because what they are is not one line of text.

what each one is
// four of them are an Input with a job on top of it
SearchInput   Input + a clear button + a debounced callback
CurrencyInput Input + grouping on blur, bound to a number
TimeInput     Input + steppers, bounds and a minute step
ColorInput    Input + the native swatch, bound to a hex string

// four draw their own box, because the shape is not a field
InputAddon    a fixed label welded to an InputGroup
PinInput      one box per character
TagsInput     chips and a field sharing one border
FileInput     a button, a filename and a clear button

Sizes and tones carry across

Every one of them takes size, tone, invalid and disabled, and means the same thing by them as Input does. Wrap any of them in Field for the label, hint and error.

What each one binds

The bound value is the part worth reading twice: three of these bind something other than a string, and one of them binds nothing at all.

ComponentBindsWhat it adds
SearchInputstringA clear button and a debounced callback beside the live value.
InputAddon—A fixed label welded to an InputGroup: a prefix, a unit, a domain.
PinInputstringOne box per character, with paste spreading and arrow-key movement.
TagsInputstring[]Free-text tokens committed on Enter, deduplicated and capped.
CurrencyInputnumber | nullGrouping applied on blur, so the bound value stays a number.
TimeInputstringSteppers, inclusive bounds and a minute step, over an HH:MM string.
ColorInputstringThe native swatch and a hex field kept in step with each other.
FileInputFile[]A one-line picker with the same type, size and count rules as Dropzone.
the eight bindings
let query = $state('');            // SearchInput  — string
let code = $state('');             // PinInput     — string, shorter means incomplete
let tags = $state<string[]>([]);   // TagsInput    — string[]
let price = $state<number | null>(null); // CurrencyInput — number, null when empty
let start = $state('09:00');       // TimeInput    — 'HH:MM', '' when empty
let brand = $state('#0f766e');     // ColorInput   — '#rrggbb'
let files = $state<File[]>([]);    // FileInput    — File[]

SearchInput

Two values, on purpose: bind:value follows every keystroke so the field stays responsive, and onsearch fires once the typing stops. The timer restarts on each keystroke rather than queueing, so a fast typist sends one request, not eight.

+page.svelte
<script lang="ts">
	import { SearchInput } from '@nqmcreative/ui/matte';

	let value = $state('');
	let results = $state<string[]>([]);
</script>

<!-- `value` updates on every keystroke; `onsearch` waits for the pause -->
<SearchInput
	bind:value
	debounce={300}
	placeholder="Search invoices"
	onsearch={async (query) => (results = await search(query))}
/>

The clear button appears only while there is something to clear, and calls onclear after emptying the field.

InputAddon

The piece InputGroup was missing. It is not the same as Input's prefix snippet: that renders inside the field's border, while an addon is its own box welded to the outside.

either side, or both
<InputGroup>
	<InputAddon>https://</InputAddon>
	<Input bind:value={handle} placeholder="studio" />
	<InputAddon>.com</InputAddon>
</InputGroup>

<InputGroup>
	<Input bind:value={weight} inputmode="decimal" />
	<InputAddon>kg</InputAddon>
</InputGroup>

Give it the same size as the field beside it — the heights have to agree, and nothing enforces that for you. variant="plain" drops the tint when the addon should read as part of the field rather than a label on it.

PinInput

The value is one compact string, so code.length is how many boxes are filled and code.length === length is "complete". A six-digit paste into the first box fills all six; a paste into the third fills the three that are left.

+page.svelte
<script lang="ts">
	import { PinInput } from '@nqmcreative/ui/matte';

	let code = $state('');
</script>

<PinInput
	bind:value={code}
	length={6}
	groupAfter={3}
	name="otp"
	oncomplete={(value) => verify(value)}
/>

Backspace on a filled box clears it, and on an empty one takes the box before — the way a caret behaves in a single field. Arrow keys move without editing. mode="alphanumeric" takes letters, mask renders dots, and name adds one hidden field so a plain form posts the whole code rather than six fragments.

TagsInput

For values that do not come from a list. MultiSelect is the one to reach for when the options are known up front; this is the one for topics, labels and anything a person invents as they go.

+page.svelte
<script lang="ts">
	import { TagsInput } from '@nqmcreative/ui/matte';

	let tags = $state(['svelte']);
</script>

<TagsInput
	bind:tags
	max={6}
	minLength={2}
	separators={[',', ' ']}
	name="topics"
	onreject={(reason) => toast.error(reason)}
/>

A refusal is not a silent no-op

A duplicate, a tag under minLength, and a full list are all refusals, and each one reaches onreject with its reason. A field that simply ignores the Enter key reads as broken, so say why.

Enter commits, as does any key in separators. Backspace in an empty field peels the last chip off. A paste is split on the same separators, so one paste can add several at once.

CurrencyInput

The bound value is a number, and null when the field is empty — which is not the same as zero, and the difference matters the moment the amount is optional.

+page.svelte
<script lang="ts">
	import { CurrencyInput } from '@nqmcreative/ui/matte';

	// A number, not a string — the field owns the formatting, you own the value.
	let price = $state<number | null>(1250000);
</script>

<CurrencyInput bind:value={price} currency="Rp" group="." decimal="," precision={0} min={0} />

Grouping is applied when the field loses focus and dropped while it has it. Formatting under the caret is what pushes the caret around mid-word, and the version of this field that does it is the version people fight with.

group and decimal are yours to set rather than Intl's to choose, because the field has to parse back what it prints — and some regions group with a space nobody can type.

TimeInput

Always HH:MM, which is what <input type="time"> posts — so a form behind this field needs no special case. Typing is forgiving: 9, 9:5 and 0905 all settle to 09:05 when the field is left.

+page.svelte
<script lang="ts">
	import { TimeInput } from '@nqmcreative/ui/matte';

	// Always `HH:MM` — the same string <input type="time"> would post.
	let start = $state('09:00');
</script>

<TimeInput bind:value={start} min="08:00" max="17:00" step={15} />

Arrow keys and the steppers move by step minutes and stop at the bounds rather than wrapping — rolling 23:55 round to 00:00 turns a late meeting into an early one.

ColorInput

The native swatch and a hex field, kept in step. Shorthand is expanded and alpha is dropped when the field settles, so the bound value is always the #rrggbb the swatch can actually show. A half-typed value is left alone rather than corrected under the caret.

+page.svelte
<script lang="ts">
	import { ColorInput } from '@nqmcreative/ui/matte';

	let brand = $state('#0f766e');
</script>

<ColorInput bind:value={brand} swatches={['#0f766e', '#b45309', '#1d4ed8']} />

FileInput

The same validation as Dropzone — accept, maxSize, maxFiles, judged in that order — in one line instead of a drop target. Reach for the drop target when files arrive by dragging, and for this when the field sits in a form with six others.

+page.svelte
<script lang="ts">
	import { FileInput } from '@nqmcreative/ui/matte';

	let files = $state<File[]>([]);
</script>

<FileInput
	bind:files
	name="avatar"
	accept="image/*,.pdf"
	maxSize={2 * 1024 * 1024}
	onreject={(rejected) => console.warn(rejected)}
/>

multiple is off by default, which is the difference from Dropzone: one file reads as its name and weight, several as a count with the list underneath.

The rules underneath

None of the behaviour above lives in a style. It is all in @nqmcreative/ui/core, which is why all three styles agree about what a duplicate tag is, and why you can use the same rules in a field this package does not ship.

@nqmcreative/ui/core
// the rules these fields run on, usable without any of them
import {
	addTag, splitTags,           // tokens: dedup, cap, paste splitting
	fillFrom, sanitisePin,       // codes: where a paste lands, what a box takes
	parseTime, clampTime,        // clock: 'HH:MM' in and out, bounds and steps
	formatGrouped, parseGrouped, // money: group on the way out, forgive on the way in
	normaliseHex,                // colour: '#abc' becomes '#aabbcc'
	sortFiles, formatSize        // files: accepted, rejected and why
} from '@nqmcreative/ui/core';
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.