Settings Page
blocklayoutLinear-style settings shell: a nav rail of icon sections (collapses to a horizontal tab strip on narrow screens) switching between fully composed panels — profile form with sticky save bar, notification switches, theme picker, workspace identity, billing summary, and a type-to-confirm danger zone. Swap the panel internals for your own forms.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/settings-page.json$npx shadcn@latest add https://uipkge.dev/r/lit/settings-page.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/settings-page.json$bunx shadcn@latest add https://uipkge.dev/r/lit/settings-page.jsonManifest:
https://uipkge.dev/r/lit/settings-page.jsonInstalls to:src/uip/blocks/settings-page/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
initial-section | SettingsSectionId | profile | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SettingsSectioninterface SettingsSection {
id: SettingsSectionId
label: string
/** Lucide icon node (React's `icon` component becomes data — see `icon()` usage below). */
icon: IconNode
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-settings-page': UipSettingsPage
}
}Files installed (1)
src/uip/blocks/settings-page/settings-page.ts15.4 kB
import { LitElement, css, html, nothing } from 'lit' import { Bell, Building2, CreditCard, Palette, TriangleAlert, User, type IconNode } from 'lucide' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/button/button' import '../../components/dialog/dialog' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/label/label' import '../../components/section-card/section-card' import '../../components/select/select' import '../../components/separator/separator' import '../../components/switch/switch' import type { UipSwitch } from '../../components/switch/switch' import '../../components/textarea/textarea' export type SettingsSectionId = 'profile' | 'notifications' | 'appearance' | 'workspace' | 'billing' | 'danger' export interface SettingsSection { id: SettingsSectionId label: string /** Lucide icon node (React's `icon` component becomes data — see `icon()` usage below). */ icon: IconNode } const defaultSections: SettingsSection[] = [ { id: 'profile', label: 'Profile', icon: User }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'workspace', label: 'Workspace', icon: Building2 }, { id: 'billing', label: 'Billing', icon: CreditCard }, { id: 'danger', label: 'Danger zone', icon: TriangleAlert }, ] // Kebab names for icon()'s lucide-<name> class (React parity). Custom // sections should reuse one of these nodes; unknown nodes fall back to // rendering without the lucide-<name> suffix. const iconNames = new Map<IconNode, string>([ [User, 'user'], [Bell, 'bell'], [Palette, 'palette'], [Building2, 'building-2'], [CreditCard, 'credit-card'], [TriangleAlert, 'triangle-alert'], ]) /** * <uip-settings-page> — the registry SettingsPage block as a web component. * * Class strings are React's SettingsPage verbatim. React state (useState) * becomes { state: true } fields. `sections` is a JS property (same ids as * React; icons are lucide IconNodes — React's component references become * data). `initialSection` is React's `initialSection` prop * (`initial-section` attribute). There are no onX callbacks in React, so no * CustomEvents. */ export class UipSettingsPage extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { sections: { attribute: false }, initialSection: { attribute: 'initial-section' }, active: { state: true }, notifyEmail: { state: true }, notifyPush: { state: true }, notifyDigest: { state: true }, themeChoice: { state: true }, deleteOpen: { state: true }, deleteConfirm: { state: true }, } sections: SettingsSection[] = defaultSections initialSection: SettingsSectionId = 'profile' private active: SettingsSectionId = 'profile' private notifyEmail = true private notifyPush = false private notifyDigest = true private themeChoice: 'light' | 'dark' | 'system' = 'system' private deleteOpen = false private deleteConfirm = '' constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'settings-page') this.active = this.initialSection } private toggleRow(heading: string, description: string, checked: boolean, onChange: (v: boolean) => void) { return html` <li data-slot="settings-page" class="flex items-center justify-between gap-4 py-4"> <div> <p class="text-sm font-medium">${heading}</p> <p class="text-muted-foreground text-xs">${description}</p> </div> <uip-switch .checked=${checked} aria-label=${`Toggle ${heading.toLowerCase()}`} @change=${(e: Event) => onChange((e.target as UipSwitch).checked)} ></uip-switch> </li> ` } render() { return html` <div class="bg-background border-border flex min-h-svh flex-col rounded-xl border lg:flex-row"> <nav class="border-border shrink-0 border-b p-3 lg:w-56 lg:border-r lg:border-b-0" aria-label="Settings sections"> <div class="flex [scrollbar-width:none] gap-1 overflow-x-auto lg:flex-col lg:overflow-visible [&::-webkit-scrollbar]:hidden"> ${this.sections.map( (section) => { const isActive = this.active === section.id return html` <button type="button" aria-current=${isActive ? 'page' : nothing} @click=${() => { this.active = section.id }} class=${cn( 'focus-visible:ring-ring flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none', isActive ? section.id === 'danger' ? 'text-destructive bg-destructive/10' : 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground', )} > <span class="inline-flex [&>svg]:size-4">${icon(section.icon, iconNames.get(section.icon) ?? 'shapes')}</span> ${section.label} </button> ` }, )} </div> </nav> <div class="flex min-w-0 flex-1 flex-col"> ${this.active === 'profile' ? html` <section class="flex flex-1 flex-col"> <div class="flex-1 space-y-6 p-6"> <uip-section-card heading="Public profile" description="How teammates see you across the workspace." > <div class="space-y-4"> <div class="space-y-2"> <uip-label for="sp-name">Display name</uip-label> <uip-input id="sp-name" value="Amara Osei"></uip-input> </div> <div class="space-y-2"> <uip-label for="sp-email">Email</uip-label> <uip-input id="sp-email" value="amara@acme.com" type="email"></uip-input> </div> <div class="space-y-2"> <uip-label for="sp-bio">Bio</uip-label> <uip-textarea id="sp-bio" value="Design engineer. Builds systems, breaks assumptions." rows="3"></uip-textarea> </div> <div class="space-y-2"> <uip-label for="sp-role">Role</uip-label> <uip-select id="sp-role" value="editor" class="w-full sm:w-56" aria-label="Role"> <option value="admin">Admin</option> <option value="editor">Editor</option> <option value="viewer">Viewer</option> </uip-select> </div> </div> </uip-section-card> </div> <div class="border-border flex items-center justify-end gap-2 border-t p-4"> <uip-button variant="ghost" size="sm">Discard</uip-button> <uip-button size="sm">Save changes</uip-button> </div> </section> ` : nothing} ${this.active === 'notifications' ? html` <section class="flex-1 p-6"> <uip-section-card heading="Notifications" description="Choose what reaches you and where."> <ul class="-my-4 divide-y"> ${this.toggleRow('Email notifications', 'Mentions, assignments and approvals.', this.notifyEmail, (v) => { this.notifyEmail = v })} ${this.toggleRow('Push notifications', 'Real-time browser alerts while you work.', this.notifyPush, (v) => { this.notifyPush = v })} ${this.toggleRow('Weekly digest', 'A summary of everything, every Monday.', this.notifyDigest, (v) => { this.notifyDigest = v })} </ul> </uip-section-card> </section> ` : nothing} ${this.active === 'appearance' ? html` <section class="flex-1 p-6"> <uip-section-card heading="Appearance" description="Theme follows your system by default."> <div class="space-y-5"> <div class="grid grid-cols-3 gap-3"> ${(['light', 'dark', 'system'] as const).map( (theme) => html` <button type="button" @click=${() => { this.themeChoice = theme }} class=${cn( 'focus-visible:ring-ring rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none', this.themeChoice === theme ? 'border-primary ring-primary/20 ring-2' : 'hover:bg-accent/50', )} > <span class="block text-sm font-medium capitalize">${theme}</span> <span class="text-muted-foreground block text-xs"> ${theme === 'light' ? 'Bright surfaces' : theme === 'dark' ? 'Low-light friendly' : 'Match device'} </span> </button> `, )} </div> <uip-separator></uip-separator> <div class="flex items-center justify-between"> <div> <p class="text-sm font-medium">Reduced motion</p> <p class="text-muted-foreground text-xs">Collapse animations to instant transitions.</p> </div> <uip-switch aria-label="Toggle reduced motion"></uip-switch> </div> </div> </uip-section-card> </section> ` : nothing} ${this.active === 'workspace' ? html` <section class="flex-1 p-6"> <uip-section-card heading="Workspace" description="Identity and defaults for everyone here."> <div class="space-y-4"> <div class="space-y-2"> <uip-label for="sp-ws-name">Workspace name</uip-label> <uip-input id="sp-ws-name" value="Acme Inc"></uip-input> </div> <div class="space-y-2"> <uip-label for="sp-ws-url">URL slug</uip-label> <div class="flex"> <span class="border-border bg-muted text-muted-foreground flex items-center rounded-l-md border px-3 font-mono text-xs"> acme.uipkge.app </span> <uip-input id="sp-ws-url" value="acme" class="font-mono text-xs [&::part(control)]:rounded-l-none"></uip-input> </div> </div> <p class="text-muted-foreground text-xs">16 members · 4 pending invites</p> </div> </uip-section-card> </section> ` : nothing} ${this.active === 'billing' ? html` <section class="flex-1 p-6"> <uip-section-card heading="Billing" description="Plan, seats and invoices."> <div class="flex items-center justify-between gap-4"> <div> <p class="text-sm font-medium">Team plan · $12 / seat / month</p> <p class="text-muted-foreground mt-1 text-xs">Next invoice Sep 1, 2026 · 18 seats</p> </div> <uip-button variant="outline" size="sm">Manage billing</uip-button> </div> </uip-section-card> </section> ` : nothing} ${this.active === 'danger' ? html` <section class="flex-1 p-6"> <div class="border-destructive/40 rounded-xl border p-6"> <div class="flex items-center justify-between gap-4"> <div> <p class="text-destructive text-sm font-medium">Delete this workspace</p> <p class="text-muted-foreground mt-1 text-xs"> All projects, members and data will be permanently removed. </p> </div> <uip-button variant="destructive" size="sm" @click=${() => { this.deleteOpen = true }} >Delete…</uip-button > </div> </div> </section> ` : nothing} </div> <uip-dialog heading="Delete workspace?" description="This removes Acme Inc and all of its data for every member. This cannot be undone." class="[&::part(content)]:max-w-sm" ?open=${this.deleteOpen} @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.deleteOpen = e.detail.open }} > <uip-input .value=${this.deleteConfirm} placeholder='Type "DELETE" to confirm' @input=${(e: Event) => { this.deleteConfirm = (e.target as UipInput).value }} ></uip-input> <uip-button slot="footer" variant="outline" size="sm" @click=${() => { this.deleteOpen = false }} >Cancel</uip-button > <uip-button slot="footer" variant="destructive" size="sm" ?disabled=${this.deleteConfirm !== 'DELETE'} @click=${() => { this.deleteOpen = false }} >Delete forever</uip-button > </uip-dialog> </div> ` } } customElements.get('uip-settings-page') || customElements.define('uip-settings-page', UipSettingsPage) declare global { interface HTMLElementTagNameMap { 'uip-settings-page': UipSettingsPage } }
Raw manifest:https://uipkge.dev/r/lit/settings-page.json