Stacked form block
A New project form in one column: SvTextInput, SvTextArea with a counter, SvDropDownList owner, SvRadioGroup visibility and an SvDateTimePicker due date. Create shows errors under each field, then a toast, and adds the project to a list.
A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.
About this example
A single-column New project form for Svelte 5 built from the SvGrid UI kit: SvTextInput for the name, SvTextArea with a character counter for the description, SvDropDownList for the owner, SvRadioGroup for visibility and SvDateTimePicker in calendar-only mode for the due date. Create validates every field and shows the message under it; a valid submit puts the SvButton into its loading state, shows a toast, adds the project to a list below and resets the form.
Stacked form block - a single-column "New project" form: name (SvTextInput), description (SvTextArea with a character counter), owner (SvDropDownList), visibility (SvRadioGroup) and a due date (SvDateTimePicker in calendar-only mode). Cancel and Create (SvButton) sit right-aligned at the bottom. Submit validates every field and shows the errors inline; a valid submit shows a toast, lists the project and resets. Pure UI-kit, --sg-* themed.
Headless: createProjectForm() below owns the fields, validation, submit flow and the created list with no markup. The component under it is one styled render of that core; import createProjectForm from this file (or npx @svgrid/ui add form-layouts-stacked --headless for the core alone) to draw your own.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
How does the validation work?
The form has novalidate, so submit always runs validate(). It checks that the name has at least 3 characters and is not already taken, that an owner is picked, and that a due date is set and is not in the past. Each failure goes into an errors object that feeds the field's error and invalid props; changing a field clears its own message.
How do I send the project to my API?
Replace the timer inside submit with your request, for example fetch('/api/projects', { method: 'POST', body: JSON.stringify(project) }), and keep toast.promise around it. Every input also has a name prop, which renders a hidden input with its value, so a SvelteKit form action can read the same fields from request.formData().
What changes on a phone?
The form is one column at every width, capped at 540 px. Below 560 px the page and form padding shrink and the Cancel and Create buttons split the footer row evenly so both stay easy to tap.
Related documentation
Related articles
- Stable Row Identity in SvGrid (and why it matters) - How SvGrid tracks rows across mutations, why object reference stability is the key to correct selection and editing, and the exact patterns to use with live data feeds.
- Bundle Size of Svelte Data Grids - How to Compare - README bundle numbers are nearly useless. Here is how to measure the real delta a data grid adds to your Svelte app, and why feature-gated architectures change the math entirely.
- Reducing Re-Renders in SvGrid with $derived - Svelte 5's $derived memoizes your row pipeline so the grid recomputes only when filter inputs actually change - not on every unrelated state update.
Source code (540-block-form-layouts-stacked.svelte)
<script lang="ts" module>
/**
* Stacked form block - a single-column "New project" form: name
* (SvTextInput), description (SvTextArea with a character counter), owner
* (SvDropDownList), visibility (SvRadioGroup) and a due date
* (SvDateTimePicker in calendar-only mode). Cancel and Create (SvButton) sit
* right-aligned at the bottom. Submit validates every field and shows the
* errors inline; a valid submit shows a toast, lists the project and resets.
* Pure UI-kit, --sg-* themed.
*
* Headless: createProjectForm() below owns the fields, validation, submit
* flow and the created list with no markup. The component under it is one
* styled render of that core; import createProjectForm from this file (or
* `npx @svgrid/ui add form-layouts-stacked --headless` for the core alone)
* to draw your own.
*/
export type ProjectOption = { value: string; label: string }
export type Project = { name: string; owner: string; visibility: string; due: Date }
export type ProjectErrors = { name?: string; owner?: string; due?: string }
export type ProjectFormOptions = {
owners?: ProjectOption[]
visibilities?: ProjectOption[]
/** Earliest allowed due date; defaults to today at midnight. */
minDue?: Date
/** Called with a valid project; throw (or reject) to keep the form filled in. */
onCreate?: (project: Project) => Promise<void> | void
/** Called when a submit fails validation. */
onInvalid?: (errors: ProjectErrors) => void
}
const SAMPLE_OWNERS: ProjectOption[] = [
{ value: 'ada', label: 'Ada Lovelace' },
{ value: 'grace', label: 'Grace Hopper' },
{ value: 'alan', label: 'Alan Turing' },
{ value: 'katherine', label: 'Katherine Johnson' },
]
const SAMPLE_VISIBILITIES: ProjectOption[] = [
{ value: 'private', label: 'Private - only you and the owner' },
{ value: 'team', label: 'Team - everyone in Northwind Ops' },
{ value: 'public', label: 'Public - anyone with the link can view' },
]
export function createProjectForm(opts: ProjectFormOptions = {}) {
// Sample owners stand in when the app passes none; the due date floor is today.
const owners = opts.owners ?? SAMPLE_OWNERS
const visibilities = opts.visibilities ?? SAMPLE_VISIBILITIES
const minDue = opts.minDue ?? new Date(new Date().setHours(0, 0, 0, 0))
let name = $state('')
let description = $state('')
let owner = $state<string | number | null>(null)
let visibility = $state<string | number>('team')
let due = $state<Date | null>(null)
let errors = $state<ProjectErrors>({})
let busy = $state(false)
let created = $state<Project[]>([])
function validate(): boolean {
const e: ProjectErrors = {}
if (name.trim().length < 3) e.name = 'Name needs at least 3 characters.'
else if (created.some((p) => p.name.toLowerCase() === name.trim().toLowerCase())) e.name = 'A project with this name already exists.'
if (owner == null) e.owner = 'Pick who owns this project.'
if (!due) e.due = 'Set a due date.'
else if (due < minDue) e.due = 'The due date cannot be in the past.'
errors = e
return Object.keys(e).length === 0
}
function reset() {
name = ''; description = ''; owner = null; visibility = 'team'; due = null; errors = {}
}
async function submit(ev?: Event) {
ev?.preventDefault()
if (busy) return
if (!validate()) { opts.onInvalid?.(errors); return }
busy = true
const project: Project = {
name: name.trim(), owner: owners.find((o) => o.value === owner)?.label ?? String(owner),
visibility: String(visibility), due: due!,
}
try {
await opts.onCreate?.(project)
created = [project, ...created]
reset()
} catch {
// The form keeps what was typed so the user can retry.
} finally {
busy = false
}
}
return {
owners, visibilities, minDue,
get name() { return name },
set name(v: string) { name = v; errors.name = undefined },
get description() { return description },
set description(v: string) { description = v },
get owner() { return owner },
set owner(v: string | number | null) { owner = v; errors.owner = undefined },
get visibility() { return visibility },
set visibility(v: string | number) { visibility = v },
get due() { return due },
set due(v: Date | null) { due = v; errors.due = undefined },
get errors() { return errors },
get busy() { return busy },
get created() { return created },
validate, reset, submit,
/** Spread on your <form>. */
formProps: () => ({ novalidate: true, onsubmit: submit }),
/** Spread on a plain <input> or <textarea> when you render your own fields. */
fieldProps: (field: 'name' | 'description') => ({
name: field,
value: field === 'name' ? name : description,
oninput: (e: Event) => {
const v = (e.currentTarget as HTMLInputElement).value
if (field === 'name') { name = v; errors.name = undefined } else description = v
},
'aria-invalid': field === 'name' && errors.name ? ('true' as const) : undefined,
}),
}
}
</script>
<script lang="ts">
import {
SvTextInput, SvTextArea, SvDropDownList, SvRadioGroup, SvDateTimePicker,
SvButton, SvBadge, SvToaster, toast,
} from '@svgrid/grid'
const form = createProjectForm({
onInvalid: () => toast.warning('Fix the highlighted fields'),
// Replace with your request; a thrown error leaves the form filled in.
onCreate: (p) =>
toast.promise(new Promise<void>((res) => setTimeout(res, 900)), {
loading: 'Creating project...',
success: `${p.name} created`,
error: 'Could not create the project',
}),
})
function cancel() {
form.reset()
toast('Form cleared')
}
</script>
<main class="sv-stacked">
<form class="sv-stacked-sheet" {...form.formProps()}>
<header>
<h2>New project</h2>
<p class="sv-stacked-muted">Projects group tasks, files and dashboards. You can change all of this later.</p>
</header>
<SvTextInput
label="Project name" name="name" placeholder="Q3 warehouse audit" maxlength={60} block required
bind:value={form.name} error={form.errors.name} invalid={!!form.errors.name}
/>
<SvTextArea
label="Description" name="description" placeholder="What is this project for?" rows={4}
maxlength={280} showCount block bind:value={form.description} hint="Optional. Shown on the project card."
/>
<SvDropDownList
label="Owner" name="owner" placeholder="Choose an owner" options={form.owners} value={form.owner} block required
error={form.errors.owner} invalid={!!form.errors.owner} onChange={(v) => (form.owner = v)}
/>
<SvRadioGroup
label="Visibility" name="visibility" options={form.visibilities} value={form.visibility}
onChange={(v) => (form.visibility = v)}
/>
<SvDateTimePicker
label="Due date" name="due" placeholder="yyyy-mm-dd" formatString="yyyy-MM-dd"
dropDownDisplayMode="calendar" min={form.minDue} value={form.due} block required
error={form.errors.due} invalid={!!form.errors.due} onChange={(d) => (form.due = d)}
/>
<footer class="sv-stacked-actions">
<SvButton variant="ghost" onclick={cancel} disabled={form.busy}>Cancel</SvButton>
<SvButton type="submit" variant="primary" loading={form.busy}>Create project</SvButton>
</footer>
</form>
{#if form.created.length}
<section class="sv-stacked-created" aria-label="Created projects">
<h3>Created this session</h3>
<ul>
{#each form.created as p (p.name)}
<li>
<strong>{p.name}</strong>
<span class="sv-stacked-muted">{p.owner} - due {p.due.toLocaleDateString()}</span>
<SvBadge size="sm" variant="neutral">{p.visibility}</SvBadge>
</li>
{/each}
</ul>
</section>
{/if}
</main>
<SvToaster position="bottom-right" />
<style>
.sv-stacked {
flex: none;
display: flex; flex-direction: column; align-items: center; gap: 16px;
min-height: 560px; padding: 32px 24px; box-sizing: border-box;
background: var(--sg-bg-subtle, #f8fafc); color: var(--sg-fg, #0f172a);
}
.sv-stacked-sheet {
display: flex; flex-direction: column; gap: 18px; width: 100%; max-width: 540px; box-sizing: border-box;
padding: 28px; background: var(--sg-bg, #fff);
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px);
}
header h2 { margin: 0 0 4px; font-weight: 700; font-size: 20px; letter-spacing: -.01em; }
.sv-stacked-muted { margin: 0; color: var(--sg-muted, #64748b); font-size: 13px; }
.sv-stacked-actions {
display: flex; justify-content: flex-end; gap: 8px;
padding-top: 16px; border-top: 1px solid var(--sg-border, #e2e8f0);
}
.sv-stacked-created { width: 100%; max-width: 540px; }
.sv-stacked-created h3 { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: var(--sg-muted, #64748b); }
.sv-stacked-created ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sv-stacked-created li {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; font-size: 13.5px;
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
}
.sv-stacked-created li :global(.sv-badge) { margin-left: auto; text-transform: capitalize; }
@media (max-width: 560px) {
.sv-stacked { padding: 16px 12px; }
.sv-stacked-sheet { padding: 20px 16px; }
.sv-stacked-actions { flex-wrap: wrap; }
.sv-stacked-actions :global(> *) { flex: 1; }
}
</style>More Blocks examples
- Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
- Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
- Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
- Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
- App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.