snow-uiProhome

SaaS settings & billing

Profile, team and roles, plans and invoices, API keys and an audit log, as long scannable forms beside a section index.

Workspace owners, admins and developers. Shell: settings layout in a grouped-sidebar app shell. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.

What's included

Pages (registry:page)

Each page is a React component (a client component) that you mount from a route of your app with the data it shows; it reads the adapter, the routes and the formatting from SettingsTemplateProvider.

#ItemComponent and its dataSuggested route
S1profile-pageProfilePage (viewer, profile)/settings/profile
S2workspace-pageWorkspacePage (viewer, workspace, members)/settings/workspace
S3members-pageMembersPage (viewer, members, invites, roles, permissions, seats, tab)/settings/members?tab=
S4plans-pagePlansPage (viewer, plans, comparison, subscription)/settings/plans
S5billing-pageBillingPage (viewer, subscription, usage, usagePeriod, paymentMethod, billingDetails, invoices, paymentElement)/settings/billing
S6api-keys-pageApiKeysPage (viewer, apiKeys)/settings/api-keys
S7audit-log-pageAuditLogPage (viewer, initial, query, actors, actions, retentionDays, planName)/settings/audit-log?q=&actor=&action=&days=&page=&size=
—settings-templateSettingsTemplateProvider, SettingsShell, useSettingsTemplate, useSettingsState, useSettingsViewer, settingsErrorMessage, saveFilethe settings routes' layout

S8 Notifications (P2 in the design) is not part of this release.

Blocks (registry:block)

ItemWhat it is
app-shell-sidebarThe grouped-sidebar shell: skip link, the library Sidebar (inset, an icon rail with ⌘B / Ctrl+B, a sheet below 768px), grouped links with counts in their names, a sticky top bar (header) and the page's main. Sets the density variables and scroll padding for its sticky bars. Collapsed state: defaultCollapsed (from the sidebar_state cookie read on the server, so it renders without a flash) or controlled with open / onOpenChange.
user-menuThe account menu: links, theme, density, single-key shortcuts, sign out.
command-menuThe ⌘K / Ctrl+K palette: pages and actions in groups.
settings-layoutThe section index (sticky from lg, a "Go to section" select below), the page header and the reading (720px) or standard (1200px) column; the h1 takes the focus after a client-side navigation.
page-headerThe h1 with a breadcrumb, status, meta, description, one primary and two secondary actions, an overflow menu and optional local tabs.
settings-sectionSettingsSection (a titled group) and SettingsRow (label and description at the start, the control at the end, two columns from a 672px container).
sticky-save-barSave and Discard while the form is dirty, ⌘S / Ctrl+S, "Unsaved changes" announced; asks before a link or a reload leaves the page, and navigateGuarded(href, navigate) sends other navigations (a select, a command menu) through the same question.
filter-barA search and filters (selects, multi-selects) in a row, or behind "Filters · n" in a bottom sheet on small containers; announces the result count.
data-tableThe library Table with sortable headers (aria-sort), row menus, column priority, stacked rows below a 576px container, pagination with the range in text, and the loading, refreshing, error and empty states; sortRows and pageRows for client-side lists.
usage-metersUsage against limits: bars with the amounts in text, a warning from 80%, an alert over the limit, unlimited meters.
record-propertiesOne object's fields as a definition list, with copy buttons.
order-summaryMoney lines with a total (credits, charges, what's due), formatted by Intl.
empty-stateFirst use, no results, nothing to do, error, no permission.
pricing-plansPlan cards with a monthly / yearly switch, the current plan, a disabled action with its reason, and a comparison table in words.
toggle-matrixRows × columns of checkboxes or switches, each named "Row — Column"; locked columns or cells say why. A table from a 576px container, one group per row below.
danger-zoneDestructive actions confirmed in an AlertDialog; the irreversible ones need the name typed; your own fields in the dialog (a new owner, a password).

The template also uses Auth's choice-cards, file-dropzone, invite-dialog, secret-reveal, password-input, password-strength and otp-input.

Shared items

settings-types (SettingsAdapter, the data types, SettingsResult, settleSettings, auditQueryFromParams / auditQueryToParams), use-follow-search (the pages following the URL), format (createFormat: numbers, money in minor units, dates in a time zone, relative times against a fixed "now"), density (the layout variables and ThemePreference), and the mock data: mock-core and mock-settings (createMockSettingsAdapter, MOCK_SETTINGS_RULES, MOCK_NOW, mockSettingsData(scenario), mockAuditEvents, queryAuditEvents, auditCsv). No page or block imports the mock data: install mock-settings only to try the pages before your backend is wired (a registry test checks that no installable item reaches it, even indirectly).

The whole template: settings

@snow-ui-pro/settings is a registry:item without files of its own: its registryDependencies are every page and block above.

Install

Add the two registries to components.json (see installation), then:

Terminal
npx shadcn@latest add @snow-ui-pro/settings
# Optional: the mock backend, to click through the pages first.
npx shadcn@latest add @snow-ui-pro/mock-settings

Or only some pages (@snow-ui-pro/members-page…), or a single block (@snow-ui-pro/data-table). npm dependencies: react-hook-form and @phosphor-icons/react.

Wire it up (Next.js App Router)

The provider holds the adapter, the routes, the formatting and the pages' edits between client-side navigations. Put it in the layout of the settings routes:

tsx
// app/settings/settings-provider.tsx
'use client'

import type { Route } from 'next'
import NextLink from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import type { ReactNode } from 'react'
import { SettingsTemplateProvider } from '@/components/snow-ui-pro/templates/settings/SettingsTemplate'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { settingsAdapter } from '@/lib/settings' // your SettingsAdapter

const Link: LinkComponent = ({ href, ...props }) => (
  <NextLink href={href as Route} {...props} />
)

export function SettingsProvider({
  now,
  sidebarCollapsed,
  children,
}: { now: string; sidebarCollapsed: boolean; children: ReactNode }) {
  const router = useRouter()
  const search = useSearchParams()
  return (
    <SettingsTemplateProvider
      adapter={settingsAdapter}
      navigate={(href) => router.push(href as Route)}
      // Filters and tabs: a new URL without a server round trip, which
      // Next.js syncs with useSearchParams.
      replace={(href) => window.history.replaceState(null, '', href)}
      // The pages follow it: Back and Forward restore a page as it first
      // rendered, and its filters and tab from here.
      search={search.toString()}
      linkAs={Link}
      now={now}
      sidebarCollapsed={sidebarCollapsed}
      timeZone="Europe/Lisbon"
      workspaceName="Acme"
      domain="acme.app"
      onAppearanceChange={({ theme }) => {
        // Apply (and persist in a cookie, so the server renders it).
        const html = document.documentElement
        if (theme === 'system') html.removeAttribute('data-theme')
        else html.setAttribute('data-theme', theme)
      }}
      onSignOut={() => router.push('/sign-in')}
    >
      {children}
    </SettingsTemplateProvider>
  )
}
tsx
// app/settings/layout.tsx — "now" from the server, so relative times match;
// the sidebar as the person left it (the library's cookie), without a flash
import { readSidebarState } from '@holakirr/snow-ui'
import { headers } from 'next/headers'
import { SettingsProvider } from './settings-provider'

export default async function Layout({ children }: { children: React.ReactNode }) {
  const collapsed = readSidebarState((await headers()).get('cookie')) === false
  return (
    <SettingsProvider now={new Date().toISOString()} sidebarCollapsed={collapsed}>
      {children}
    </SettingsProvider>
  )
}
tsx
// app/settings/members/page.tsx — the data from your backend, on the server
import { MembersPage, type MembersTab } from '@/components/snow-ui-pro/templates/settings/MembersPage'
import { getMembersData } from '@/lib/settings'

type Props = { searchParams: Promise<{ tab?: string }> }

export default async function Page({ searchParams }: Props) {
  const { tab } = await searchParams
  const data = await getMembersData()
  return <MembersPage {...data} tab={tab as MembersTab | undefined} />
}
tsx
// app/settings/audit-log/page.tsx — the filters live in the URL
import { AuditLogPage } from '@/components/snow-ui-pro/templates/settings/AuditLogPage'
import { auditQueryFromParams } from '@/components/snow-ui-pro/lib/settings-types'
import { getAuditData, listAuditEvents } from '@/lib/settings'

type Props = { searchParams: Promise<Record<string, string | undefined>> }

export default async function Page({ searchParams }: Props) {
  const query = auditQueryFromParams(await searchParams)
  const data = await getAuditData()
  return <AuditLogPage {...data} query={query} initial={await listAuditEvents(query)} />
}

The audit log's filters and the members' tab follow the URL: after Back, Forward or a link they say what the URL says, and the events reload. Give the provider your router's search: a router may restore a page the way it first rendered it (Next.js does on Back and Forward), and the URL then wins over the page's first props. Without search the pages follow the browser's Back and Forward (popstate) between their own URLs only.

SettingsShell (used by every page) is the template's minimal host app: the sidebar with Home, Settings and Help, search, the account menu and the settings index. In your app, render the pages' content inside your own AppShellSidebar (or any shell) with SettingsLayout. routes defaults to /settings/…; override any of them (home, help, twoFactorSetup — Auth's U5 —, apiDocs).

The adapter

Resolve with { ok: false, error } for expected failures; reject only when the request failed — the pages show it as a network error with "Try again".

MethodUsed byResult
updateProfile(update), uploadAvatar(file)S1SettingsResult; the avatar's URL
requestEmailChange(email, password), confirmEmailChange(email, code), resendVerification()S1invalid-password, invalid-code
changePassword(current, next), signOutSession(id), deleteAccount()S1invalid-password
updateWorkspace(update), uploadLogo(file), checkSlug(slug)S2slug-taken; { available, suggestion }
transferOwnership(memberId, password), deleteWorkspace()S2invalid-password
changeRole(memberId, role), removeMember(memberId, reassignTo?), invite(invites, message?), resendInvite(id), revokeInvite(id), updateRole(roleId, permissions)S3last-owner; the new invites
previewPlanChange(planId, interval), changePlan(planId, interval)S4PlanChangePreview (credit, charge, due today, next invoice, over-limit lines); payment-required
updateBillingDetails(details), checkTaxId(taxId), replacePaymentMethod(), downloadInvoice(id)S5{ valid }; the card to display; { filename, url | blob }
createApiKey(key), rollApiKey(id), revokeApiKey(id)S6{ key, secret }: the secret is shown once
listAuditEvents(query), exportAuditEvents(filters)S7{ events, total }; a CSV Blob

Payments

No page handles card numbers or takes a payment. "Replace" on the billing page opens a dialog with a clearly marked slot: pass your payment provider's hosted card element as paymentElement, and resolve replacePaymentMethod() with what to display (brand, last four, expiry) once the provider has saved it. Plan changes show the proration your provider computes (previewPlanChange) before changePlan; a downgrade whose preview lists usage over the new limits (overLimits) can't be confirmed.

Not revealing data

  • The email change sends a code to the new address and doesn't say whether the address is in use; a conflict surfaces only after the code proves the address is the person's.
  • Deleting the account, removing a member, revoking a key and deleting the workspace are confirmed in a dialog that names the object; the irreversible ones need the name typed.
  • The CSV export prefixes cells that start with =, +, -, @ (CSV injection); keep that in your server's export.

The mock rules

createMockSettingsAdapter({ latencyMs, scenario, failOn }) answers after a fixed delay (0 in tests, 400 on the site) by these rules — anything else succeeds:

InputResult
password wrong-passwordwrong password (email change, password change, transfer)
code 000000wrong email-change code
offline@example.comthe email change or the invite fails (network)
address bluefin, cobalt, takentaken, with …-hq suggested
a tax ID other than DEMO- + 6 digitsfails the check
failOn: ['changeRole', …]those methods fail like a dropped connection

Scenarios (?scenario= on the site): default, trialing (an alert on Plans, an unverified email), past-due (plan changes disabled, a failed invoice), over-limit (an API meter over its limit), non-owner (an admin: the danger zone disabled). The clock is MOCK_NOW (16 June 2026, 09:16 in Lisbon).

Customising

  • Strings. Every block takes messages (English defaults). The pages are yours to edit: their copy is in the page components.
  • Density. AppShellSidebar density (or data-density with densityClasses on your own root) sets --pro-section-gap, --pro-block-gap, --pro-panel-pad, --pro-row-height and --pro-list-row; compact rows apply to fine pointers only. The account menu switches it; persist it like the theme.
  • Theme. The profile's theme previews at once and reverts on Discard; the account menu applies it. Both go through onAppearanceChange.
  • Field states (library 5.2). The address and tax ID checks use Input status="progress" | "success" with a statusLabel, buttons use loading, destructive menu items variant="destructive".

Accessibility

  • One h1 per page; after a client-side navigation between settings pages the new h1 takes the focus. The shell starts with a skip link to main; the top bar is a header, the sidebar a nav ("Primary"), the section index a nav ("Settings").
  • Forms follow the Pro form rules: a changed field is checked when it loses focus; on save, the first invalid field takes the focus and the save bar counts the errors. Busy buttons keep their label (loading) and a second press is ignored.
  • Async checks announce "Checking" and the result through the field's status; filters announce the result count; toasts carry Undo or "Try again" when an action needs it.
  • Focus after changes: removing a member, revoking a key or signing out a session moves the focus to the next row; a new key focuses its row once the secret dialog closes; closing a dialog returns to its trigger.
  • The page scrolls focused fields clear of the sticky top bar and the save bar (WCAG 2.4.11).
  • Checked in the site's end-to-end tests: every page at 375, 768 and 1280px in the light and dark themes with axe (WCAG 2.2 AA), no sideways scrolling from 320px, the scenario states, right to left, and each page's flows with the keyboard only, including the destructive confirmations and the error states.

Not yet

  • The settings pages don't use data-table's row selection and bulk actions or record-properties' editing in place (they came with the Commerce template, its guide).
  • At 768–1023px the sidebar starts expanded (the library's default) rather than as an icon rail; ⌘B / Ctrl+B or the menu button collapses it, and the choice is kept in a cookie (read on the server: sidebarCollapsed above).