Notification settings block
Events by Email, Push and SMS with an SvSwitchButton per cell and an SvSegmented digest frequency. A sticky save bar counts unsaved changes; Save stores them and Discard restores the saved state.
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 notification settings page for Svelte 5 built from the SvGrid UI kit: a table of event types by channel with an SvSwitchButton in every cell, and an SvSegmented control for the email digest frequency. A sticky save bar with an SvBadge change count appears only when the choices differ from the saved ones; Save stores them and Discard restores the saved state.
Notification settings block - a table of event types (rows) by channel (Email, Push, SMS) with one SvSwitchButton per cell, plus an SvSegmented digest frequency. A sticky save bar slides in only when the choices differ from the saved ones: Save (SvButton with loading) stores them, Discard puts the saved state back. SvBadge marks the unsaved count. Pure UI-kit, --sg-* themed.
Headless: createNotificationSettings() below owns the saved and edited preferences, the unsaved-change count and the save and discard flow with no markup. The component under it is one styled render of that core; import createNotificationSettings from this file (or npx @svgrid/ui add form-layouts-settings --headless for the core alone) to draw your own.
Imports, features and API used
Imports: @svgrid/grid
Frequently asked questions
How does the save bar know something changed?
The block keeps two copies: saved and prefs. Switches and the digest control edit prefs, and a $derived count compares every cell and the digest against saved. The bar renders only while that count is above zero, so flipping a switch back hides it again.
What do Save and Discard do?
Save runs toast.promise around a timer and, on success, copies prefs into saved with $state.snapshot, which hides the bar. Discard copies saved back into prefs. Replace the timer with a PUT of prefs to your API to persist it.
Does the table fit on a phone?
Yes. The channel columns shrink to 48 px with tighter padding below 560 px, the event names wrap, and the save bar wraps its buttons onto a second line when they no longer fit beside the change count.
Related documentation
Source code (542-block-form-layouts-settings.svelte)
<script lang="ts" module>
/**
* Notification settings block - a table of event types (rows) by channel
* (Email, Push, SMS) with one SvSwitchButton per cell, plus an SvSegmented
* digest frequency. A sticky save bar slides in only when the choices differ from
* the saved ones: Save (SvButton with loading) stores them, Discard puts the
* saved state back. SvBadge marks the unsaved count. Pure UI-kit,
* --sg-* themed.
*
* Headless: createNotificationSettings() below owns the saved and edited
* preferences, the unsaved-change count and the save and discard flow with
* no markup. The component under it is one styled render of that core;
* import createNotificationSettings from this file (or `npx @svgrid/ui add
* form-layouts-settings --headless` for the core alone) to draw your own.
*/
const CHANNEL_IDS = ['email', 'push', 'sms'] as const
export type NotifChannel = (typeof CHANNEL_IDS)[number]
export type NotifEvent = { id: string; label: string; note: string; noSms?: boolean }
export type NotifPrefs = { events: Record<string, Record<NotifChannel, boolean>>; digest: string | number }
export type NotificationSettingsOptions = {
channels?: { id: NotifChannel; label: string }[]
events?: NotifEvent[]
digestOptions?: { value: string; label: string }[]
/** What the server has. Load this from your API. */
stored?: NotifPrefs
/** Called with the edited preferences; throw (or reject) to keep them unsaved. */
onSave?: (prefs: NotifPrefs) => Promise<void> | void
onDiscard?: () => void
}
const SAMPLE_CHANNELS: { id: NotifChannel; label: string }[] = [
{ id: 'email', label: 'Email' }, { id: 'push', label: 'Push' }, { id: 'sms', label: 'SMS' },
]
const SAMPLE_EVENTS: NotifEvent[] = [
{ id: 'assigned', label: 'Task assigned to me', note: 'When someone hands you a task' },
{ id: 'mention', label: 'Mentions', note: '@you in a comment or a doc' },
{ id: 'due', label: 'Due date reminders', note: '24 hours before a task is due' },
{ id: 'deploy', label: 'Failed deploys', note: 'Pipelines you own' },
{ id: 'billing', label: 'Invoices and billing', note: 'Receipts, failed payments', noSms: true },
]
const SAMPLE_DIGEST = [
{ value: 'off', label: 'Off' }, { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' },
]
const SAMPLE_STORED: NotifPrefs = {
events: {
assigned: { email: true, push: true, sms: false },
mention: { email: true, push: true, sms: false },
due: { email: false, push: true, sms: false },
deploy: { email: true, push: true, sms: true },
billing: { email: true, push: false, sms: false },
},
digest: 'weekly',
}
export function createNotificationSettings(opts: NotificationSettingsOptions = {}) {
const channels = opts.channels ?? SAMPLE_CHANNELS
const events = opts.events ?? SAMPLE_EVENTS
const stored = opts.stored ?? SAMPLE_STORED
// saved = what the server has; prefs = the editable copy.
let saved = $state<NotifPrefs>(structuredClone(stored))
let prefs = $state<NotifPrefs>(structuredClone(stored))
let busy = $state(false)
const changes = $derived(
events.reduce((n, e) => n + channels.filter((c) => prefs.events[e.id]![c.id] !== saved.events[e.id]![c.id]).length, 0)
+ (prefs.digest !== saved.digest ? 1 : 0),
)
/** False for a cell the channel cannot deliver (SMS for billing). */
const available = (eventId: string, channel: NotifChannel) =>
!(channel === 'sms' && events.find((e) => e.id === eventId)?.noSms)
const isOn = (eventId: string, channel: NotifChannel) => prefs.events[eventId]![channel]
function set(eventId: string, channel: NotifChannel, on: boolean) {
prefs.events[eventId]![channel] = on
}
async function save() {
if (busy) return
busy = true
try {
await opts.onSave?.($state.snapshot(prefs))
saved = $state.snapshot(prefs)
} catch {
// Edits stay; the save bar stays up.
} finally {
busy = false
}
}
function discard() {
prefs = $state.snapshot(saved)
opts.onDiscard?.()
}
return {
channels, events,
digestOptions: opts.digestOptions ?? SAMPLE_DIGEST,
get prefs() { return prefs },
get saved() { return saved },
get digest() { return prefs.digest },
set digest(v: string | number) { prefs.digest = v },
get changes() { return changes },
get dirty() { return changes > 0 },
get busy() { return busy },
available, isOn, set, save, discard,
/** Spread on a plain <button> to render one cell as a switch. */
switchProps: (eventId: string, channel: NotifChannel) => ({
type: 'button' as const,
role: 'switch' as const,
'aria-checked': isOn(eventId, channel),
'aria-label': `${events.find((e) => e.id === eventId)?.label ?? eventId} by ${channels.find((c) => c.id === channel)?.label ?? channel}`,
onclick: () => set(eventId, channel, !isOn(eventId, channel)),
}),
}
}
</script>
<script lang="ts">
import { SvSwitchButton, SvSegmented, SvButton, SvBadge, SvToaster, toast } from '@svgrid/grid'
const notif = createNotificationSettings({
// Replace with your request; a thrown error keeps the edits unsaved.
onSave: () =>
toast.promise(new Promise<void>((res) => setTimeout(res, 800)), {
loading: 'Saving notification settings...',
success: 'Notification settings saved',
error: 'Could not save settings',
}),
onDiscard: () => toast('Changes discarded'),
})
</script>
<main class="sv-notif">
<header>
<h2>Notifications</h2>
<p class="sv-notif-muted">Choose where Fieldline tells you about each event. SMS goes to +44 7700 900123.</p>
</header>
<div class="sv-notif-table-wrap">
<table>
<thead>
<tr>
<th scope="col" class="sv-notif-ev">Event</th>
{#each notif.channels as c (c.id)}<th scope="col">{c.label}</th>{/each}
</tr>
</thead>
<tbody>
{#each notif.events as e (e.id)}
<tr>
<th scope="row" class="sv-notif-ev">
<span class="sv-notif-ev-label">{e.label}</span>
<span class="sv-notif-ev-note">{e.note}</span>
</th>
{#each notif.channels as c (c.id)}
<td>
{#if !notif.available(e.id, c.id)}
<span class="sv-notif-na" title="Not available by {c.label}">n/a</span>
{:else}
<SvSwitchButton
size="sm" ariaLabel="{e.label} by {c.label}" checked={notif.isOn(e.id, c.id)}
onChange={(v) => notif.set(e.id, c.id, v)}
/>
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<section class="sv-notif-digest">
<div>
<h3>Email digest</h3>
<p class="sv-notif-muted">One email with everything you were not notified about directly.</p>
</div>
<SvSegmented ariaLabel="Digest frequency" options={notif.digestOptions} bind:value={notif.digest} />
</section>
{#if notif.dirty}
<div class="sv-notif-savebar" role="region" aria-label="Unsaved changes">
<span><SvBadge variant="warning" size="sm">{notif.changes}</SvBadge> unsaved {notif.changes === 1 ? 'change' : 'changes'}</span>
<div class="sv-notif-bar-actions">
<SvButton variant="ghost" onclick={notif.discard} disabled={notif.busy}>Discard</SvButton>
<SvButton onclick={notif.save} loading={notif.busy}>Save changes</SvButton>
</div>
</div>
{/if}
</main>
<SvToaster position="bottom-right" />
<style>
.sv-notif {
flex: none; width: 100%;
display: flex; flex-direction: column; gap: 20px;
min-height: 560px; max-width: 760px; margin: 0 auto; padding: 32px 24px 24px; box-sizing: border-box;
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
}
h2 { font-weight: 700; margin: 0 0 4px; font-size: 22px; letter-spacing: -.01em; }
h3 { font-weight: 600; margin: 0 0 2px; font-size: 14px; }
.sv-notif-muted { margin: 0; font-size: 13px; color: var(--sg-muted, #64748b); }
.sv-notif-table-wrap { border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px); overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
thead th {
padding: 10px 12px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b);
background: var(--sg-header-bg, #f8fafc); border-bottom: 1px solid var(--sg-border, #e2e8f0);
}
td, tbody th { padding: 12px; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
tbody tr:last-child > * { border-bottom: 0; }
tbody tr:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
td { width: 76px; text-align: center; }
.sv-notif-ev { text-align: left; font-weight: 400; }
.sv-notif-ev-label { display: block; font-weight: 550; }
.sv-notif-ev-note { display: block; font-size: 12px; color: var(--sg-muted, #64748b); margin-top: 2px; }
.sv-notif-na { font-size: 12px; color: var(--sg-muted, #94a3b8); }
.sv-notif-digest { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.sv-notif-savebar {
position: sticky; bottom: 16px; margin-top: auto;
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 10px 12px 10px 16px; font-size: 13.5px;
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px);
box-shadow: 0 8px 24px color-mix(in srgb, var(--sg-fg, #0f172a) 14%, transparent);
animation: sv-notif-rise .18s ease-out;
}
.sv-notif-savebar span { display: inline-flex; align-items: center; gap: 8px; }
.sv-notif-bar-actions { display: flex; gap: 8px; }
@keyframes sv-notif-rise { from { transform: translateY(8px); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .sv-notif-savebar { animation: none; } }
@media (max-width: 560px) {
.sv-notif { padding: 20px 12px; }
td { width: 48px; padding: 10px 4px; }
thead th { padding: 10px 4px; }
tbody th, thead th.sv-notif-ev { padding: 10px 8px 10px 12px; }
.sv-notif-bar-actions { flex: 1; justify-content: flex-end; }
}
</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.