Native Shell
Native Shell은 모든 플랫폼의 앱 안에서 만들어지는 AI UI입니다
의견이 반영된 Svelte 5와 shadcn-svelte 컴포넌트 라이브러리
Opinionated Svelte 5 + shadcn-svelte component library: one-liner form fields with zod v4 validation, infinite-scroll selects backed by REST, and a virtualized TanStack data table.
개발 도구Svelte · TypeScriptGitHub @iamleson98온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗simple ui — an opinionated, batteries-included component library for Svelte 5, built on top of shadcn-svelte, bits-ui, Tailwind CSS v4 and zod v4.
sui exists to remove boilerplate. A typical form field needs a label, helper text, icons, validation wiring, error display, a size, and a skeleton for its loading state — that is eight concerns before any business logic. sui collapses them into props:
<!-- everything a production form field needs, in one component -->
<SuiInput
label="Email"
placeholder="[email protected]"
startIcon={MailIcon}
schema={z.email('Enter a valid email')}
bind:value={email}
/>
<!-- an async, server-searchable select with infinite scroll -->
<SuiSelect label="Owner" source={cursorSource(fetchOwners)} bind:value={ownerId} />
<!-- or wire the ENTIRE form from one zod schema — values, validation
timing and error display all automatic -->
<SuiForm {form}>
<SuiInput field={form.fields.email} label="Email" type="email" required />
<SuiCheckbox field={form.fields.accept} label="I accept the terms" />
<SuiButton type="submit">Create account</SuiButton>
</SuiForm>
No separate <Label>, no <FormField>, no validation framework glue, no scroll listener code, no manual safeParse/error mapping.
| Area | Components & exports |
|---|---|
| Form controls | SuiInput, SuiTextarea, SuiSelect, SuiCombobox, SuiMultiSelect, SuiCheckbox, SuiRadioGroup, SuiSwitch |
| Date & time | SuiDatePicker, SuiDateRangePicker, SuiTimePicker, SuiDateTimePicker (+ skeletons, ISO helpers isoToDate, dateToIso, timeSlots, …) |
| Actions | SuiButton, SuiIconButton |
| Data | SuiDataTable (+ suiColumn helper, renderComponent), SuiDataTableSkeleton, CSV helpers (suiDownloadCsv, suiRowsToCsv, suiCsvCell) |
| Error handling | SuiErrorSummary (WCAG 3.3.1 error box), focusFirstInvalid() |
| Skeletons | SuiInputSkeleton, SuiTextareaSkeleton, SuiSelectSkeleton, SuiComboboxSkeleton, SuiMultiSelectSkeleton, SuiCheckboxSkeleton, SuiRadioGroupSkeleton, SuiSwitchSkeleton, SuiButtonSkeleton, SuiSkeleton |
| Infinite scroll | SuiSource, offsetSource(), cursorSource(), SuiInfiniteList, observeSentinel() |
| Mobile | suiMobileQuery() / SUI_MOBILE_QUERY — the breakpoint where the select family switches to bottom sheets |
| Validation | zod v4 helpers — suiValidate(), SuiFieldState — plus the schema prop on every form control |
| Schema-driven forms | createSuiForm(), <SuiForm>, form.fields.* handles, form.array() lists, suiParseFormData / suiEnhance for SvelteKit actions (one zod schema wires values, timing, errors, submit parsing) |
| Localisation | setSuiStrings(), SUI_STRINGS — every built-in string is overridable per subtree (i18n-ready, reactive) |
| Design tokens | SuiSize scale + semantic variant maps (SUI_CONTROL, SUI_FIELD_CONTROL, …) shared by every component |
size="sm" on an input, a button, a select trigger and a skeleton all produce exactly the same height. Mixed-size rows line up pixel-for-pixel.variant="info" (blue, the normal state), "success" (green), "warning" (yellow) and "error" (red) style the label, border, focus ring and helper text together, consistently across all form controls. Validation errors always win and force the error appearance everywhere.ZodType — a primitive (z.email(...)) or a slice of an object schema (schema.shape.email) — and the field validates as the user types, with ARIA wiring (aria-invalid, aria-describedby, aria-live) handled for you. For whole forms, createSuiForm(schema) drives every field from the one schema — no safeParse, no error mapping, no per-field refs.label, subText, startIcon, endIcon, action (an interactive snippet in the field's end slot) exist on every control that can show them.<SuiInputSkeleton size="sm" label={true} />.source — a typed async page loader over cursor or offset pagination — and load more pages as the user scrolls the option list. See the pagination guide.sui ships as source — like shadcn-svelte itself — through a shadcn-svelte registry, so the files land in your project and are yours to edit:
# inside a SvelteKit project already set up with shadcn-svelte (https://shadcn-svelte.com/docs/installation)
npx shadcn-svelte@latest add https://<your-host>/r/sui.json
That copies src/lib/sui, the shadcn components it builds on (button, calendar, command, drawer, popover, select…) and sui's patched command wrappers, and installs the npm dependencies (zod, bits-ui, @internationalized/date, @tanstack/svelte-table, …). Then import sui's theme in your app.css, after Tailwind: @import './lib/sui/theme.css'; (relative to the stylesheet). It holds the colour, radius and font tokens: neutral zinc surfaces, a bright blue primary (blue-600 in light, blue-400 with navy text in dark, the info button colour), and a --muted-foreground darkened so secondary text keeps AA contrast on muted surfaces. Override a token after the import to brand your app.
Serving the registry yourself is one command — npm run registry:build writes static/r/sui.json, which this demo app then serves at /r/sui.json. Prefer manual? Copy src/lib/sui and the shadcn components listed in registry.json.
Import everything from the barrel:
import {
SuiInput,
SuiSelect,
SuiButton,
SuiDataTable,
suiColumn,
cursorSource,
offsetSource,
type SuiItem,
type SuiSource
} from '#lib/sui/index.js';
One schema drives the whole form — zod v4 is the blessed path (smart defaults + precise field typing), and any Standard Schema v1 vendor (Valibot, ArkType, Effect) works at the same boundary. createSuiForm returns a reactive form instance; pass its field handles to any sui control through the field prop and everything wires itself — values, validation timing, error display, async validators, submit parsing, even focus management:
Client-side first. All validation runs in the browser —
handleSubmitparses the schema before youronsubmitcallback ever runs. There is deliberately no server-side validation integration (no form actions, no superforms-style server round-trip) yet; thesetErrorsAPI and the throw-a-ZodErrorpattern are generic escape hatches for merging errors that your own submit code produces (e.g. a backend rejection) back onto fields.
<script lang="ts">
import { createSuiForm, SuiForm } from '#lib/sui/index.js';
import { z, ZodError } from 'zod';
const schema = z
.object({
email: z.email('Enter a valid email'),
password: z.string().min(8, 'Use at least 8 characters'),
confirm: z.string(),
accept: z.literal(true, { error: 'Please accept the terms' })
})
.refine((d) => d.password === d.confirm, {
path: ['confirm'],
error: "Passwords don't match"
});
const form = createSuiForm(schema, {
// centralised copy / i18n — rescues vendor default messages
messages: { email: 'Enter an address like [email protected]' },
// async validators: debounced, cancellable, submit-flushed
asyncValidators: {
email: async (v) => (await api.available(v)) || 'Already registered'
},
rewardValid: true, // green "reward early" ring on valid fields
onsubmit: async (data) => {
// data is the parsed, typed, transformed output
await api.createAccount(data);
// server-side validation failures map straight back onto
// fields (ZodError, or any issue-carrying error):
if (taken(data.email))
throw new ZodError([
{ code: 'custom', input: data.email, path: ['email'], message: 'Already registered' }
]);
}
});
</script>
<!-- showFormErrors renders form.formErrors (root refines, server-level
rejections) as a focusable banner above the content -->
<SuiForm {form} showFormErrors>
{#if form.hasErrors}<SuiErrorSummary errors={form.errorSummary} id="errors" />{/if}
<SuiInput field={form.fields.email} label="Email" type="email" required />
<SuiInput field={form.fields.password} label="Password" type="password" required />
<SuiInput field={form.fields.confirm} label="Confirm" type="password" required />
<SuiCheckbox field={form.fields.accept} label="I accept the terms" required />
<SuiButton type="submit" loading={form.isSubmitting}>Create account</SuiButton>
</SuiForm>
That's the whole feature — the application code never calls safeParse, never maps issue.path, never threads errors props or bind:this refs.
| Option | Default | Notes |
|---|---|---|
initialValues | schema-derived | Seed values layered over smart defaults: '' for strings, false for booleans, [] for arrays, {} for nested objects, z.default() values, undefined for enums/literals/numbers |
validateOn | 'auto' | Form-wide timing — auto (blur first, then eager), blur, change, both, none |
debounce | 0 | Debounce (ms) for change-driven validation |
fields | — | Per-field overrides merged over the globals: { email: { validateOn: 'change', debounce: 150 } } |
messages | — | Centralised message overrides (copy / i18n), keyed by field path — ''/'_form' for form-level, '*' wildcard; string or (issue) => string |
asyncValidators | — | { path: async (value, values) => message or string[] or true } — run after the sync schema passes, debounced, cancellable, flushed on submit |
asyncDebounce | 400 | Debounce (ms) for async validators (per-field override: fields.path.asyncDebounce) |
checkingMessage | 'Checking…' | Text controls show while a field's async validator runs (spinner included) |
focusOnSubmit | 'summary' | Failed-submit focus: 'summary' (error summary box, GOV.UK pattern) → banner → first invalid field; or 'field' / 'none' |
rewardValid | false | Revealed, valid, answered fields earn a subtle success ring ("reward early") |
onsubmit | — | Receives the parsed output; throw a ZodError (or any issue-carrying error) to map server issues back onto fields (any other error becomes ) |
The timing follows the same research as the per-field mode (Baymard / react-hook-form onTouched / superforms auto), extended to the whole-schema level the way superforms and react-hook-form do it:
refine can attach an error to any field, so cross-field rules (password ≠ confirm) stay fresh the moment the other field changes. Reveal gating keeps the noise invisible on fields the user hasn't earned errors for yet. One zod caveat to know: zod v4 skips an object's .refine only after aborting issues (a key missing or of the wrong type); format and length failures (z.email(), .min(8)) do not abort, so the refinement still runs on values that are themselves invalid — and sui starts string fields at ''. Guard cross-field rules on the values they compare: .refine((d) => !d.start || !d.end || d.end > d.start, …).isSubmitted semantics). <SuiForm> then focuses the error summary box when one is rendered (the GOV.UK pattern — screen readers announce the whole problem list at once, and its links walk users to each field), falling back to the form-error banner, then to the first invalid control on desktop (WCAG 3.3.1); on mobile it scroll-snaps instead, where focus would open the on-screen keyboard and hide the message.form.setErrors({ email: 'Already registered' }) — or a ZodError thrown from onsubmit — displays immediately and survives blurring; the first edit of that field hands display back to the schema (editing another field never wipes it), and the next server round-trip replaces the previous verdict, so stale messages ("Network error", "Already registered") never linger (superforms tainted-field behavior).onsubmit is still running (double click, Enter mashing) joins the in-flight attempt instead of calling onsubmit again; a loading SuiButton additionally blocks activation while keeping keyboard focus.path) show in the banner once a submit reveals them — not while the user is still filling the form in. Issues on array items or nested keys display on the control bound to their parent (tags.1 on the multi-select bound to ), and errors no rendered control can show (a schema key without a field, an unmounted step) surface in 's banner as .form.values; // live, deeply reactive — edit directly
form.fields.email; // handle: .value, .errors, .invalid, .touched, .dirty,
// .isValidating, .valid, .rewardValid, .checkingMessage
form.field('address.city'); // nested paths use dotted keys
form.isValid; // silent full-schema check (no display changes)
form.formErrors; // root refine (after a submit) + server-level errors (render via showFormErrors)
form.unmappedErrors; // errors no rendered control can show — <SuiForm> banners them
form.errorSummary; // ready-made entries for <SuiErrorSummary>
form.isValidating; // paths with async validators currently running
form.dirty / isDirty; // paths that differ from the initial values (unsaved changes)
form.isSubmitting / isSubmitted / submitCount;
// programmatic control
form.validate(); // full validation, reveals everything
form.setErrors({ email: ['Taken'] }); // server errors ('' or '_form' = form-level)
form.setValues({ email: '[email protected]' }); // silent merge
form.reset(); // values → initial values, state forgotten
form.reset(user); // …or make a loaded record the new pristine baseline (edit forms)
form.handleSubmit(event); // manual submit handler for plain <form> elements
// server payloads → setErrors shape, one line:
import { suiErrors } from '#lib/sui/index.js';
form.setErrors(suiErrors.fromResponse(await response.json()));
// or .fromIssues(issues) / .fromZodError(error)
Nested object schemas work with dotted paths: form.fields['address'] binds the whole sub-object, form.field('address.city') reaches one leaf. Sync schemas are parsed on every trigger (~1.4 µs/parse for a 9-field schema — the whole-schema-per-keystroke design is effectively free); async checks belong in asyncValidators (field-level, debounced, checking state) or onsubmit (submit-time). Non-zod schemas derive no smart defaults — pass initialValues.
All form controls share this core API:
| Prop | Type | Notes |
|---|---|---|
label | string | Snippet | Rendered above the field, associated for screen readers |
subText | string | Helper text below the field — stays visible (and aria-describedby-associated) when errors appear, per GOV.UK |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | Shared height/typography scale (default md) |
variant | 'info' | 'success' | 'warning' | 'error' | Semantic color state (default info) |
startIcon / endIcon | lucide component | Icons inside the field |
action | Snippet | Interactive content in the field's end slot (buttons, availability checks, …) |
schema | zod or any Standard Schema | Validated per validateOn timing (see below). Ignored when field is set |
field | SuiFieldHandle | Schema-driven wiring — a field handle from createSuiForm (form.fields.email). Takes over the value, validation timing and error display; bind:value/schema/errors become unnecessary (see Schema-driven forms) |
name | string | The native form name (pickers and toggles render hidden inputs, so FormData, SvelteKit actions and autofill see every field) and the schema path createSuiSubmitter maps the control to. Defaults to the field handle's path |
validateOn | 'auto' | 'change' | 'blur' | 'both' | 'none' | When the schema runs — auto for text fields, both for pickers/toggles (defaults) |
errors |
form.array()"Add another email", invoice lines, repeatable addresses. Items have stable keys, and every piece of per-item state — errors, touched, dirty, async checks, server messages — follows the item it describes through inserts, removals and moves:
<script lang="ts">
const schema = z.object({
emails: z.array(z.email('Enter a valid email')).min(1, 'Add at least one email'),
lines: z.array(z.object({ sku: z.string().min(1), qty: z.number().min(1) }))
});
const form = createSuiForm(schema, { initialValues: { emails: [''] } });
const emails = form.array('emails'); // typed from the schema
const lines = form.array('lines'); // items are { sku: string; qty: number }
</script>
{#each emails.entries as item (item.key)}
<SuiInput field={item.field()} label="Email {item.index + 1}" type="email" />
<SuiIconButton icon={TrashIcon} label="Remove" onclick={() => emails.remove(item.index)} />
{/each}
<SuiButton onclick={() => emails.push()}>Add email</SuiButton>
{#each lines.entries as item (item.key)}
<SuiInput field={item.field('sku')} label="SKU" />
<SuiInput field={item.field('qty')} label="Qty" type="number" />
{/each}
push(value?) (the element schema's default when omitted), insert, remove, move, swap, clear and replace. remove() moves focus to the neighbouring item (WCAG 2.4.3) and reveals the list's own rule (min(1)), a server error on emails.1 keeps pointing at the same email after earlier items are removed, and in-flight async checks of moved items are cancelled rather than attributed to the wrong row.
Typed paths. form.field('address.city'), form.field(`lines.${i}.sku`) and form.array('lines') are checked against the schema — a typo is a compile error — and the handle carries the value's type. Schemas that cannot be introspected (other Standard Schema vendors) accept plain strings.
One schema validates live in the browser and authoritatively in the action; both speak one error shape, and the form works without JavaScript (every control renders its field path as the input name):
// +page.server.ts
export const actions = {
signup: async ({ request }) => {
const parsed = suiParseFormData(schema, await request.formData());
if (!parsed.ok) return fail(400, { errors: parsed.errors, values: parsed.values });
await createAccount(parsed.data); // typed + transformed
}
};
<!-- +page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form: result } = $props();
const form = createSuiForm(schema, {
initialValues: result?.values, // a no-JS failure keeps what was typed…
initialErrors: result?.errors // …and shows the messages, server-rendered
});
</script>
<SuiForm {form} method="POST" action="?/signup" {@attach suiEnhance(form, enhance)}>…</SuiForm>
suiParseFormData rebuilds nested objects and arrays from address.city / lines[0].sku / repeated keys and coerces to what the schema expects (checkbox 'on' → true including z.literal(true), '' → undefined for numbers, absent arrays → []); passwords listed in passwordFields are never echoed back. With JavaScript, suiEnhance gates the request on the client schema (and async validators), cancels invalid submits with the usual focus management, maps fail() payloads back onto fields and applies SvelteKit's update() without resetting the controlled DOM. See /server-form in the demo.
Keep your own bind:value state and still delete the whole hand-rolled submit handler. Give each control a name matching the schema, hand the schema to createSuiSubmitter, and it collects values, parses, maps issues onto fields, lands focus on the first problem and hands you typed data — the Felte / formisch / react-hook-form handleSubmit shape, minus the boilerplate:
<script lang="ts">
import { createSuiSubmitter } from '#lib/sui/index.js';
const submit = createSuiSubmitter(schema, {
onvalid: async (data) => await api.save(data) // zod-parsed + typed
});
</script>
<form onsubmit={submit.handleSubmit} novalidate>
<SuiInput name="name" label="Name" bind:value={name} />
<SuiSelect name="role" label="Role" {items} bind:value={role} />
<SuiCheckbox name="accept" label="I accept the terms" bind:checked={accept} />
<SuiButton type="submit" loading={submit.isSubmitting}>Save</SuiButton>
</form>
A failed submit highlights every invalid field at once (editing one clears just its error, no re-submit needed) and focus lands on the error summary when one is rendered, else the first problem — scrolled into view on touch devices. A ZodError (or any issue-carrying error) thrown from onvalid maps back onto fields exactly like createSuiForm's onsubmit; root refine issues collect in submit.formErrors. Issues on array items or nested keys land on the control registered for their parent (topics.0 on the multi-select named topics), and issues no named control can show collect in submit.unmappedErrors — render them next to submit.formErrors so a submit never fails silently. A submit fired while onvalid runs joins the in-flight attempt. The submitter accepts the same messages copy map, and its focusOnSubmit option ('summary' | 'field' | 'none') mirrors the form engine's. Because this mode keeps the values in your hands, it composes with non-sui fields and page-owned state; when the form engine can own the values, prefer createSuiForm.
Controls bound to a field handle ignore name — the engine already drives them. Individual fields can still validate standalone via the schema prop (see Validation timing); every control also exports validate(): string[] and reset() methods for fully manual wiring.
Inline-validation research (Baymard) and the major form libraries (react-hook-form's onTouched, superforms' auto) agree on the sweet spot, and sui encodes it as the default:
SuiInput, SuiTextarea) default to validateOn="auto" — quiet while the user types their first answer, validate on the first blur, then revalidate on every keystroke so errors clear the moment they are fixed.SuiSelect, SuiCombobox, SuiMultiSelect, SuiCheckbox, SuiRadioGroup, SuiSwitch) default to validateOn="both" — every selection/toggle is a completed answer, so change and blur both validate.change / blur / none give you explicit control; a failed submit (validate()) marks fields touched, which turns on eager revalidation everywhere.External error lifecycle: messages passed via errors (e.g. server-side results after a failed submit) display immediately and survive blurring — but the first edit of a field hands error display back to the local schema, so a stale "Name must be at least 2 characters" disappears as soon as the user fixes the value. A new errors array from the next submit takes the display back. No bookkeeping required.
SuiInput adds type, placeholder, validateDebounce (ms), ref (bindable). SuiTextarea mirrors it for multi-line text. Both accept validateOn (see Validation timing) and keep text typed before hydration on prerendered pages (the value goes through Svelte's own bind:value machinery).
Number fields: with type="number" schemas validate — and createSuiForm / createSuiSubmitter collect — a number (undefined while empty), so z.number('Enter your age').min(18) works as-is. No z.coerce.number(), whose '' → 0 coercion silently passes an empty required field.
Change callbacks: SuiCheckbox / SuiSwitch take onCheckedChange(checked) and SuiRadioGroup takes onValueChange(value) (the checkbox and switch are <button>s, so a native onchange never fires on them).
SuiSelect renders a trigger + listbox with keyboard navigation, descriptions, disabled options and an optional clear button (clearable). Every field renders one root element ([data-sui-field]) containing the label, control and message — parent grid/flex gaps can never pull them apart.
items: SuiItem[] — see belowsource: SuiSource<SuiItem> — the first page is prefetched, and streaming the next page is automatic: as the user scrolls towards the bottom of the list the component pre-fetches the next page (256 px before the end) and appends its items, so scrolling just continues. A Loading more… row signals in-flight pages and fetch failures surface through errorText with a role="alert" message.selectedItem (selectedItems on the multi-select) to label an initial value from your record before its page loads — edit forms never show raw ids.source request shows errorText with a Try again button; auto-loading on scroll pauses until the user retries (no request storm against a failing API).<button type="button"> rendered outside the trigger (nested interactive elements are invalid HTML) — clicking it clears the selection without opening the menu and returns focus to the trigger. While it is visible the chevron is pinned to the trigger's end edge and the ✕ sits between the value and the chevron, so there is no dead gap before the border.focus-visible (not focus-within): the menu hands DOM focus back to the trigger on close, and only keyboard focus paints the focused border. Dismissing the menu with an outside click lets the focus follow the pointer out of the field; Esc and keyboard selection return focus to the trigger as keyboard users expect.scrollIntoView(), which races floating-ui positioning — in the frames before the portal becomes position: fixed the item still sits at the top of the document flow, and the native call would scroll the page to "reveal" it. The vendored ui/command wrappers (contained-scroll-into-view.ts, applied in command-item.svelte + command-group.svelte) replace that call with a container-scoped implementation that only ever scrolls the command list. Keyboard navigation still reveals items inside the list — just without hijacking the document. Keep this local deviation when re-syncing the shadcn wrappers.type SuiItem = { value: string; label: string; description?: string; disabled?: boolean };
SuiCombobox adds a search box: for static items it filters client-side; for async sources typing debounces a server search (250 ms by default — searchDebounce). The trigger announces proper combobox semantics (aria-haspopup="listbox", aria-controls → the listbox) and shows a chevron that flips while open.
SuiMultiSelect binds value: string[] and renders the selection as chips with individual remove buttons. The trigger is a role="combobox" div (so the chip removes can be real buttons) and collapses the overflow smartly (maxDisplay):
maxDisplay="responsive" (default) — Ant Design maxTagCount="responsive" behaviour: chips are measured and as many fit in the trigger as the width allows, the rest collapse into a “+n” pill (hover shows the hidden labels, click expands to show every chip with a “Show less” control)maxDisplay={3} — a fixed capBelow 640px (the sm breakpoint) every select-family control swaps its anchored popover for a drag-to-dismiss bottom sheet (vaul) — the platform-native picker pattern on phones. The branch is chosen live by an SSR-safe media query (suiMobileQuery()), so there is no hydration mismatch and no layout shift when the viewport crosses the breakpoint:
env(safe-area-inset-bottom))SuiSelect / SuiCombobox commits and closes, SuiMultiSelect stays open for multi-selectionOn coarse pointers the demo app.css ships the ergonomics rules sui's data attributes are designed for (copy the block into your app):
touch-action: manipulation on every trigger — kills the 300ms double-tap-zoom delay-webkit-tap-highlight-color — no grey tap flash::after insets) on the small round affordances: clear buttons, chip removes, the "+n" pilloverscroll-behavior: contain on open dropdowns — scroll chains never leak to the page behindprefers-reduced-motion disables entrance/exit animationsSuiInput also derives mobile keyboard niceties from type: inputmode (email / tel / url / decimal) plus autocapitalize="none" / autocorrect="off" on email fields. Pass your own inputmode to override.
Three pickers share the form-control API (label, subText, sizes, variants, schema, field, name, errors, clearable, required, skeletons) and follow the research consensus (GOV.UK, USWDS, React Aria): typing always works — one keyboard-editable segment per unit, in the locale's order — and the calendar or time list is a shortcut, never the only way in.
<!-- scheduling: typed segments + calendar popover -->
<SuiDatePicker
label="Delivery date"
min={todayIso}
isDateUnavailable={isWeekend}
bind:value={delivery}
/>
<!-- memorable dates (date of birth): typing only — calendars slow these down -->
<SuiDatePicker label="Date of birth" calendar={false} subText="For example, 27 3 1990" />
<!-- typed time + a keyboard-navigable slot list (every `step` minutes within min/max) -->
<SuiTimePicker label="Meeting" step={15} min="09:00" max="17:30" bind:value={meeting} />
<!-- date AND time: the popover pairs the calendar with a time column -->
<SuiDateTimePicker label="Starts at" step={30} bind:value={startsAt} />
Ranges: SuiDateRangePicker binds { start, end } (ISO strings) with typed start/end segments, a two-month calendar (one on phones), optional presets ("Next 7 days") and min/max/isDateUnavailable. Only complete ranges are written back, so a half-picked range never shows "Choose a check-out date" mid-selection; inside a form it is a z.object({ start, end }) field and posts name.start / name.end.
Values are plain ISO strings — exactly what native <input type="date|time|datetime-local"> produce: '2026-10-11', '14:30' ('14:30:05' with granularity="second"), '2026-10-11T14:30' (local). They validate with z.iso.date(), z.iso.time() and z.iso.datetime({ local: true }), compare correctly as strings in refinements, serialise to JSON and post through FormData (a hidden input carries the bound value). min/max/isDateUnavailable/isDateDisabled take ISO strings too; isoToDate, dateToIso, isoToTime, timeToIso, isoToDateTime, dateTimeToIso and timeSlots convert to and from @internationalized/date when you need date math.
locale (per field, or app-wide via setSuiStrings({ locale })).captionLayout="dropdown" adds month/year selects for distant dates. Day cells grow to 40px on touch screens.2026-10-20) fills the field; IME / autofill inserts and Android keyboards (which send no usable key codes) are replayed through the segments instead of being lost or corrupting them.SuiButton and SuiIconButton share one API: the same variants (primary, secondary, outline, ghost, destructive, link), the same sizes (xs–xl, identical heights — icon buttons are exactly square), loading, href (renders an anchor), a bindable ref, and color:
<!-- semantic colours — the field palette, combined with any variant -->
<SuiButton color="success">Approve</SuiButton>
<SuiButton color="warning" variant="secondary">Review</SuiButton>
<SuiButton color="error" variant="outline">Reject</SuiButton>
<SuiIconButton icon={CheckIcon} label="Approve" color="success" variant="ghost" />
<!-- any CSS colour for brand buttons -->
<SuiButton color="#7c3aed">Upgrade</SuiButton>
<SuiButton color="var(--brand)" variant="secondary">Learn more</SuiButton>
The colour becomes a --sui-btn token; the secondary tint, outline border, ghost/link text and hover shades are derived from it with color-mix, pulled toward the theme foreground so they stay readable in light and dark mode. The four semantic colours meet WCAG AA (≥ 4.5:1) in every variant and theme. With a custom colour, text on solid fills is white — pick a dark enough colour or set style="--sui-btn-fg: …".
loading shows a spinner and blocks activation (no double submits) while keeping focus on the button (aria-disabled, not disabled).
The source pattern is sui's answer to "load options from the API without writing scroll code":
import { offsetSource, cursorSource, type SuiSource } from '#lib/sui/index.js';
// ── offset pagination: ?page=1&size=25 ──────────────────────────────
type OffsetPage<T> = { items: T[]; total: number };
const users: SuiSource<SuiItem> = offsetSource(async ({ page, size, query, signal }) => {
const res = await fetch(`/api/users?page=${page}&size=${size}&q=${query}`, { signal });
const body: OffsetPage<SuiItem> = await res.json();
return { items: body.items, total: body.total }; // hasMore inferred
});
// ── cursor pagination (recommended): ?cursor=…&size=25 ──────────────
type CursorPage<T> = { items: T[]; nextCursor: string | null };
const owners: SuiSource<SuiItem> = cursorSource(async ({ cursor, size, query, signal }) => {
const params = new URLSearchParams({ size: String(size) });
if (cursor) params.set('cursor', cursor);
if (query) params.set('q', query);
const res = await fetch(`/api/users?${params}`, { signal });
const body: CursorPage<SuiItem> = await res.json();
return { items: body.items, nextCursor: body.nextCursor };
});
Both adapters give the component everything it needs and nothing more: pages are deduplicated by item.value, stale responses are superseded, aborted requests reuse the AbortSignal, and hasMore flips off when the API says the list ended. SuiInfiniteList is the headless engine behind this — usable on its own for custom list UIs.
REST best practices baked in:
nextCursor: string | null — null ends the list (no "empty page means done" guessing)total so the component can stop exactly at the last pagesignal so superseded searches are cancelled server-sideSuiDataTable wraps TanStack Table v9 + TanStack Virtual:
<script lang="ts">
import { SuiDataTable, suiColumn, renderComponent } from '#lib/sui/index.js';
import type { SuiDataTableColumn } from '#lib/sui/index.js';
type Person = { id: string; name: string; age: number };
const col = suiColumn<Person>();
const columns = [
col.accessor('name', { header: 'Name' }),
col.accessor('age', { header: 'Age', meta: { align: 'right' } }),
col.display({
id: 'actions',
header: '',
cell: ({ row }) => renderComponent(RowActions, { person: row.original })
})
] as SuiDataTableColumn<Person>[];
</script>
<SuiDataTable
data={people}
{columns}
rowId={(p) => p.id}
enableSelection
searchable
onSelectionChange={(rows, ids) => (chosen = rows)}
onRowClick={(person) => open(person)}
/>
Features: sorting (none → asc → desc, aria-sort wired, keyboard-operable header buttons), global search (page count and footer follow the filtered rows; a new search returns to page one), column visibility menu, row selection with a select-all and live footer count, pagination (client, server or none — onPageChange + rowCount for server mode, where search is delegated through onSearchChange instead of filtering one page client-side), keyboard-operable clickable rows (onRowClick + Enter/Space), density sizes, loading/empty states, and virtual scrolling on by default (virtual={true} renders only the visible window of a 10,000-row list).
Column pinning — freeze columns to an edge while the rest scroll horizontally with meta.pinned:
const columns = [
col.accessor('name', { header: 'Name', meta: { pinned: 'left', width: 120 } }),
col.accessor('email', { header: 'Email', meta: { width: 220 } }),
col.display({
id: 'actions',
header: '',
meta: { pinned: 'right', width: 90 },
cell: ({ row }) => renderComponent(RowActions, { person: row.original })
})
] as SuiDataTableColumn<Person>[];
Pinned columns should declare an explicit width (or size); pinned cells are locked to exactly that width, so the sticky offsets always line up. With row selection enabled the checkbox column pins along with them. Pinned cells and the sticky header are opaque in every row state (hover, selected, focus — light and dark), so scrolled content never shows through; freeze edges get a hairline + soft inner shadow so it reads as passing underneath. Pinning composes with virtual scrolling.
CSV export — exportable adds a toolbar button that downloads the current filtered rows, in the on-screen sort order, as RFC 4180 CSV (exportFilename to rename). Values are quoted/escaped correctly, dates serialize as ISO, text starting with = / + / - / @ is prefixed to neutralize spreadsheet formula injection (real numbers like -42 are left intact), display columns are skipped, and the file ships with a UTF-8 BOM so Excel opens it cleanly. The suiDownloadCsv / suiRowsToCsv / suiCsvCell helpers are exported for custom export flows.
Column meta options: align, width, class, hiddenByDefault, pinned.
Every interactive component has a size-matched skeleton with the same public sizes, plus label / labelSkeleton props to reserve the label row:
{#if loading}
<SuiSelectSkeleton size="md" label={true} />
{:else}
<SuiSelect label="Owner" {source} bind:value={owner} />
{/if}
SuiButtonSkeleton matches button sizes/widths; SuiDataTableSkeleton takes rows / columns counts; SuiMultiSelectSkeleton takes badges for a number of placeholder pills. SuiSkeleton is the raw primitive.
Every string sui renders by itself — placeholders, loading/empty/error states, button labels, screen-reader text, the data-table toolbar and footer — comes from SUI_STRINGS. Override any subset for a subtree with setSuiStrings (typically once, in the root layout); per-instance props such as placeholder or emptyText still win:
<!-- +layout.svelte -->
<script lang="ts">
import { setSuiStrings } from '#lib/sui/index.js';
import * as m from '#lib/paraglide/messages';
// a function stays reactive: switching locale re-renders the copy
setSuiStrings(() => ({
selectPlaceholder: m.select_placeholder(),
noResults: m.no_results(),
tableRowCount: (count) => m.rows({ count })
}));
</script>
for/id), required markers with sr-only textaria-invalid + aria-describedby + aria-live on every field messagesubText keeps its own id and stays visible when errors appear; aria-describedby references both ids in reading order$props.id() — server and client agree, and nothing depends on crypto.randomUUID (which only exists on HTTPS/localhost, so plain-HTTP intranet hosts work)role="combobox" triggers on every picker (select, combobox, multi-select), listbox/option roles, aria-expanded, aria-required; multi-select options expose aria-checkedaria-sort header cells; clickable rows take Enter/Space; selection uses real checkboxes with aria-labelsrole="status"; fetch failures use role="alert" with a retry buttonrole="group"s of role="spinbutton" segments (value text announced per unit); calendars are keyboard-navigable grids; time slots are listboxes with arrow-key navigationaria-disabled + blocked activation) — disabled would drop keyboard and screen-reader focus to <body> mid-actionSuiErrorSummary (focusable, tabindex="-1") + focusFirstInvalid() implement the WCAG 3.3.1 / GOV.UK failed-submit pattern: the summary box receives focus and announces the whole problem list; its links focus each field without hash navigationChecking… state (spinner + text) so its latency is never invisiblearia-modal, labelled by the field label, keyboard-dismissablesui ships with the two-tier test setup the library itself uses:
npm run test # vitest + @testing-library/svelte (427 unit tests)
npm run test:e2e # Playwright: desktop + mobile projects (78 tests, incl. axe-core accessibility audits)
npm run test:e2e:update # regenerate visual baselines
npm run lint # ESLint (typescript-eslint + eslint-plugin-svelte)
CI (.github/workflows/ci.yml) runs formatting, svelte-check, the unit suite and the behavioral e2e suites on every push and pull request; the visual baselines are platform-specific and run locally. The e2e fixture (tests/e2e/fixtures.ts) waits for hydration after every navigation, so no test interacts with inert prerendered HTML.
The unit suite covers props/ARIA/wiring in jsdom; the Playwright suite runs against the built demo app in a real browser — a desktop Chromium project (infinite scroll, floating positioning, focus management, full form flows, pinned-column geometry, CSV downloads, visual baselines) plus a Pixel 7 mobile project (bottom-sheet behavior, 16px inputs, coarse-pointer ergonomics).
The repo's routes are a live showcase of every component — run npm run dev and browse:
| Route | Shows |
|---|---|
/ | overview + philosophy |
/input | labels, icons, actions, variants, zod-as-you-type, sizes, skeletons |
/button | variants, icons, loading, shared size scale |
/selection | static + infinite-scroll selects, searchable combobox, smart chip overflow, variant showcase, mobile bottom sheets |
/data-table | sorting, search, selection, pagination, column visibility, pinned columns, CSV export, 10k virtual rows |
/toggles | checkbox / radio / switch with zod |
/date-time | date, time and date-time pickers — calendar, time slots, memorable dates, a zod-validated booking form, locales, sizes |
/field-arrays | dynamic lists: add / remove / reorder with per-item errors, typed paths, focus management |
/server-form | a SvelteKit form action: client gate, server validation, progressive enhancement without JavaScript |
/validation | a schema-driven createSuiForm form (async availability check with live Checking… state, message overrides, form-error banner, GOV.UK summary focus, reward ring, server-error mapping) + a hand-wired createSuiSubmitter form + per-field timing playground |
/pagination | the infinite-scroll REST guide with live examples |
/skeletons | every skeleton, every size |
/showcase | Nimbus — a full mission-control app (sidebar shell, ⌘K command palette, charts, kanban board, virtualized deployments table + live logs, scheduling, settings, toasts) assembled from the entire toolkit |
The demo app itself doubles as the deployment reference: it prerenders every page to static HTML (the Kit config in vite.config.ts picks adapter-vercel when the VERCEL=1 build env var is set — prerendered pages served statically, /api mock endpoints as serverless functions — and adapter-node everywhere else: node build/index.js, Fly.io, Railway, Render, Docker), ships per-route code splitting via rolldown codeSplitting groups (the table engine only downloads on /data-table; icons and the sui root helpers live in one cached chunk each), self-hosts the latin subset of Inter (48 KB woff2, preloaded — not the 232 KB all-subsets bundle), and wires the full SEO set per route: meta description, canonical, Open Graph/Twitter cards, robots.txt and a generated sitemap.xml. Copy any of it from src/lib/demo/seo.svelte, src/lib/demo/site.ts and vite.config.ts.
The repo ships one house style, enforced by Prettier and EditorConfig: tab indentation, tab width 2 — a hard tab per level, rendered two columns wide in the editor. Quotes are single, semicolons stay, lines wrap at 100 columns, trailing commas are off, and Svelte templates / Tailwind class order sort automatically (prettier-plugin-svelte, prettier-plugin-tailwindcss).
npm run format # write everywhere
npm run format:check # CI-style check
.editorconfig + .vscode/settings.json make VS Code (and every EditorConfig-aware editor) insert tabs at width 2 and format on save — contributors get the style for free.
Built on shadcn-svelte (design system; the sui primitives wrap the 10 controls they need, and the /showcase app installs the rest of the registry — sidebar, charts, calendar, carousel, sonner, …), bits-ui (headless behaviors), vaul-svelte (mobile bottom sheets), LayerChart (the chart primitives behind shadcn-svelte charts), TanStack Table + TanStack Virtual, and zod. Icons by lucide.
Apache-2.0
form.formErrorstags<SuiForm>form.unmappedErrorssignal — the validator's third argument — aborts its fetch; in-flight runs for the same value are deduped, so no duplicate network calls), memoised per value, surfaced as a Checking… state (field.isValidating) and taint-cleared exactly like server errors. A submit flushes any stale checks before onsubmit runs, so a form is never "submitted valid" while an availability check is still pending.messages: { email: 'Enter an address like [email protected]' } (string or resolver) fixes it centrally, i18n-ready. Server-side messages are never overridden — server copy is authoritative.string[] |
| External errors (server-side validation) — shown until the user edits the field |
required | boolean | Adds the visual * marker; the control itself announces it (required / aria-required) — once, never doubled by label text |