Menu

Change language

GitHub repo

Settings Page

blocklayout

Linear-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

$npx shadcn@latest add https://uipkge.dev/r/lit/settings-page.json
Manifest:https://uipkge.dev/r/lit/settings-page.jsonInstalls to:src/uip/blocks/settings-page/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
initial-sectionSettingsSectionIdprofileoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SettingsSection
interface SettingsSection {
  id: SettingsSectionId
  label: string
  /** Lucide icon node (React's `icon` component becomes data — see `icon()` usage below). */
  icon: IconNode
}
HTMLElementTagNameMap
interface 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="[email protected]" 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