Skip to content
ShineUI
Install

FormLayout

Forms with a spine.

<sui-form-layout><sui-form-actions><sui-save-status>Text inputsStable

About

Form card: an optional heading row (title, subtitle, a status line and a trailing slot), a responsive column grid for the fields, and an action bar. Children of the default slot lay out in the grid; give a child data-span="full" to span every column. Dirty tracking and autosave wiring are per app; this is the visual contract.

Usage

<sui-form-layout heading="Edit invoice" columns="2"> <sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-textarea label="Memo" rows="2" data-span="full" value="Net 30 terms."></sui-textarea> <sui-form-actions slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status slot="status" state="dirty"></sui-save-status> </sui-form-actions> </sui-form-layout> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); </script>
import { SuiFormLayout } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return ( <SuiFormLayout heading="Edit invoice" columns="2"> <sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-textarea label="Memo" rows="2" data-span="full" value="Net 30 terms."></sui-textarea> <sui-form-actions slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status slot="status" state="dirty"></sui-save-status> </sui-form-actions> </SuiFormLayout> ); }
<script setup> import { SuiFormLayout } from "@shineui/vue"; import "@shineui/core/shineui.css"; </script> <template> <SuiFormLayout heading="Edit invoice" columns="2"><sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-textarea label="Memo" rows="2" data-span="full" value="Net 30 terms."></sui-textarea> <sui-form-actions slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status slot="status" state="dirty"></sui-save-status> </sui-form-actions></SuiFormLayout> </template>
import { Component } from "@angular/core"; import { SuiFormLayout } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiFormLayout], template: `<sui-form-layout heading="Edit invoice" columns="2"><sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-textarea label="Memo" rows="2" data-span="full" value="Net 30 terms."></sui-textarea> <sui-form-actions slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status slot="status" state="dirty"></sui-save-status> </sui-form-actions></sui-form-layout>`, }) export class Example { }

Basic

Fields lay out in the column grid; data-span="full" spans every column. The action bar slots in as actions, and a status line inside it sits at the start of the bar.

<sui-form-layout heading="Edit invoice" columns="2"> <sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-textarea label="Memo" rows="2" data-span="full" value="Net 30 terms."></sui-textarea> <sui-form-actions slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status slot="status" state="dirty"></sui-save-status> </sui-form-actions> </sui-form-layout>

Heading and status

subtitle adds a muted line under the heading. A status slotted on the layout itself sits in the heading row rather than the bar.

<sui-form-layout heading="Bank details" subtitle="Used for vendor payments." columns="1"> <sui-save-status slot="status" state="saved" time="2 min ago"></sui-save-status> <sui-input-text label="Routing number" value="083000108"></sui-input-text> <sui-input-text label="Account number" value="4402118890"></sui-input-text> </sui-form-layout>

Trailing header content

header-end sits at the end of the heading row, after any status line, the way sui-card places its own: a status pill, or a small action such as an edit button. The row renders for it alone, with no heading.

<sui-form-layout heading="Bank details" subtitle="Used for vendor payments." columns="2"> <sui-tag slot="header-end" label="Verified" severity="positive" size="sm"></sui-tag> <sui-input-text label="Routing number" value="083000108" readonly></sui-input-text> <sui-input-text label="Account number" value="4402118890" readonly></sui-input-text> </sui-form-layout>

Save status

Each of the four states is a polite live region, so a screen reader announces a change without focus moving. time is used only on the saved line.

<div style="display:grid;gap:10px"> <sui-save-status state="saved"></sui-save-status> <sui-save-status state="saving"></sui-save-status> <sui-save-status state="dirty"></sui-save-status> <sui-save-status state="error"></sui-save-status> </div>

Submitting a form

primary-type="submit" runs the browser validation over the fields and blocks on the first invalid one; secondary-type="reset" restores them.

Submit while empty to see the fields block it.
<form id="fl-invoice"> <sui-form-layout heading="New invoice" subtitle="Both fields are required." columns="2"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" money required></sui-input-number> <sui-form-actions slot="actions" primary-type="submit" secondary-type="reset" primary-label="Save invoice" secondary-label="Reset"></sui-form-actions> </sui-form-layout> <output id="fl-invoice-out" style="display:block;margin-top:12px;font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit while empty to see the fields block it.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#fl-invoice'); const out = root.querySelector('#fl-invoice-out'); form.addEventListener('submit', (e) => { e.preventDefault(); out.textContent = [...new FormData(form)].map(([k, v]) => `${k}=${v}`).join(' '); }); }

Bar outside the form

A sticky bar rendered beside the card reaches the form by id through the form attribute. sticky pins it to the bottom of the scroll container.

<form id="fl-detached"> <sui-form-layout heading="Bank details" columns="2"> <sui-input-text label="Routing number" name="routing" value="083000108"></sui-input-text> <sui-input-text label="Account number" name="account" value="4402118890"></sui-input-text> </sui-form-layout> </form> <sui-form-actions form="fl-detached" primary-type="submit" secondary-type="reset" primary-label="Save details" secondary-label="Reset"> <sui-save-status slot="status" state="saved"></sui-save-status> </sui-form-actions>

Events

suiPrimary and suiSecondary fire on the click, whether or not the browser then accepts a submit. primary-loading shows the spinner while a save is in flight and suppresses suiPrimary.

Idle.
<sui-form-layout heading="Edit invoice" columns="2"> <sui-input-text label="Customer" value="Silver Hills Church"></sui-input-text> <sui-input-number label="Amount" money value="6420"></sui-input-number> <sui-form-actions id="fl-actions" slot="actions" primary-label="Save invoice" secondary-label="Cancel"> <sui-save-status id="fl-status" slot="status" state="dirty"></sui-save-status> </sui-form-actions> </sui-form-layout> <span id="fl-log" style="display:block;margin-top:12px;font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span>
// setup(root): properties and events setup(root) { const bar = root.querySelector('#fl-actions'); const status = root.querySelector('#fl-status'); const log = root.querySelector('#fl-log'); bar.addEventListener('suiPrimary', async () => { log.textContent = 'suiPrimary'; bar.primaryLoading = true; status.state = 'saving'; await new Promise((r) => setTimeout(r, 600)); bar.primaryLoading = false; status.state = 'saved'; }); bar.addEventListener('suiSecondary', () => { log.textContent = 'suiSecondary'; status.state = 'dirty'; }); }

Disabled until dirtysui-form-actions

primary-disabled greys the Save button while there is nothing to save, and hide-secondary drops the outline button for a bar with one action. Both are live, so a field edit can arm the bar and a finished save can disarm it.

Nothing to save.
<div style="display:flex;flex-direction:column;gap:12px"> <sui-input-text id="fa-field" label="Display name" value="Falls City Supply Co." style="max-width:360px"></sui-input-text> <sui-form-actions id="fa-bar" primary-label="Save name" primary-disabled hide-secondary></sui-form-actions> <span id="fa-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing to save.</span> </div>
// setup(root): properties and events setup(root) { const field = root.querySelector('#fa-field'); const bar = root.querySelector('#fa-bar'); const log = root.querySelector('#fa-log'); field.addEventListener('suiInput', () => { bar.primaryDisabled = false; log.textContent = 'Unsaved change.'; }); bar.addEventListener('suiPrimary', () => { bar.primaryDisabled = true; log.textContent = `Saved "${field.value}".`; }); }

Changing in placesui-save-status

state and time are live: a record editor writes them as a save runs and the line changes without moving focus, which the polite live region then announces. time is read only on the saved line, and with no time the line reads "Saved just now" in the language of the page.

<div style="display:flex;flex-direction:column;gap:12px;align-items:flex-start"> <sui-save-status id="ss-live" state="saved"></sui-save-status> <div style="display:flex;gap:8px;flex-wrap:wrap"> <sui-button id="ss-edit" variant="outline" size="sm" label="Edit a field"></sui-button> <sui-button id="ss-save" variant="brand" size="sm" label="Save"></sui-button> <sui-button id="ss-fail" variant="ghost" size="sm" label="Lose the connection"></sui-button> </div> </div>
// setup(root): properties and events setup(root) { const status = root.querySelector('#ss-live'); const settle = () => new Promise((r) => setTimeout(r, 700)); root.querySelector('#ss-edit').addEventListener('click', () => { status.state = 'dirty'; }); root.querySelector('#ss-save').addEventListener('click', async () => { status.state = 'saving'; await settle(); status.time = undefined; status.state = 'saved'; }); root.querySelector('#ss-fail').addEventListener('click', async () => { status.state = 'saving'; await settle(); status.state = 'error'; }); }

API

<sui-form-layout>

Properties

PropertyAttributeTypeDefaultDescription
columnscolumnsnumber2Number of grid columns.
headingheadingstringstring | undefinedCard title (the reference title prop; renamed for the native attribute).
subtitlesubtitlestringstring | undefinedMuted line under the heading.

Slots

SlotDescription
(default)Fields, laid out in the column grid. data-span="full" spans all columns.
actionsThe action bar, typically sui-form-actions.
header-endTrailing content in the heading row, after the status: a status pill or a small action, placed the way sui-card places its own. The row renders when this slot, status or heading has content.
statusStatus line in the heading row, typically sui-save-status.

<sui-form-actions>

Properties

PropertyAttributeTypeDefaultDescription
formformstringstring | undefinedId of the form both buttons act on, for a bar that sits outside the <form> element: the sticky case, a dialog footer, and any layout where the card is not itself the form. The id of a sui-form host works as it is: the bar finds the native form the element renders inside it. A <form id="customer"> of your own is named the same way.
hideSecondaryhide-secondarybooleanfalseHides the secondary button.
primaryDisabledprimary-disabledbooleanfalseDisables the primary button (e.g. nothing to save).
primaryLabelprimary-labelstringstring | undefinedPrimary (brand) button label. Verb-first.
primaryLoadingprimary-loadingbooleanfalseShows the primary button in its loading state while a save is in flight.
primaryTypeprimary-type"button" | "submit"'button'Native type of the primary button. submit makes it the form's submitter: the browser validates the fields, focuses and messages the first invalid one, and blocks the submit. The default button leaves the save to suiPrimary.
secondaryLabelsecondary-labelstringstring | undefinedSecondary (outline) button label.
secondaryTypesecondary-type"button" | "reset"'button'Native type of the secondary button. reset restores every field to the value it mounted with; the default button leaves the cancel to suiSecondary.
stickystickybooleanfalsePin the bar to the bottom of its scroll container.

Events

EventDetailReactVueDescription
suiPrimaryvoidonSuiPrimary@sui-primaryFires when the primary button is clicked, even when the browser then refuses the submit as invalid. Listen to the form's own submit event for the validated path.
suiSecondaryvoidonSuiSecondary@sui-secondaryFires when the secondary button is clicked.

Slots

SlotDescription
statusStatus line on the left, typically sui-save-status.

<sui-save-status>

Properties

PropertyAttributeTypeDefaultDescription
statestateSuiSaveState"dirty" | "error" | "saved" | "saving"'saved'Which line to show.
timetimestringstring | undefinedWhen the record was saved, in the page's language, e.g. "2 min ago". The saved line reads "Saved 2 min ago"; without it, "Saved just now".

Types

SuiSaveState type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also