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

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>

View this example on GitHub

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.