WebberUI

Anchored Settings Shell

An anchored settings-page frame — scroll-spy navigation on the left, two-column settings sections on the right, an indicator that glides along with the scroll, and a save bar that springs in when there are unsaved changes.

Loading preview…
npx shadcn@latest add https://webberui.com/r/anchored-settings-shell.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<AnchoredSettingsShell />

Installation

npx shadcn@latest add https://webberui.com/r/anchored-settings-shell.json

Or, once registries are configured in components.json, install it as @webberui/anchored-settings-shell.

Usage

import {
  AnchoredSettingsShell,
  SettingsSection,
  SettingsRow,
} from "@/components/ui/anchored-settings-shell";
import { Bell, User } from "lucide-react";

export function Example() {
  const [dirty, setDirty] = React.useState(false);

  return (
    <AnchoredSettingsShell
      dirty={dirty}
      onSave={() => setDirty(false)}
      onDiscard={() => setDirty(false)}
    >
      <SettingsSection id="profile" label="Profile" icon={User} description="Public identity and contact details">
        <SettingsRow label="Display name" htmlFor="name">
          <input id="name" onChange={() => setDirty(true)} />
        </SettingsRow>
      </SettingsSection>

      <SettingsSection id="notifications" label="Notifications" icon={Bell}>
        <SettingsRow label="Product updates">{/* ...control */}</SettingsRow>
      </SettingsSection>
    </AnchoredSettingsShell>
  );
}

The navigation items are derived automatically from the id, label, and icon of the SettingsSection children, so their order always matches the content.

Uncontrolled mode

Omit the dirty prop to use internal state instead: call the function returned by useSettingsDirty() in a form field's onChange to mark unsaved changes. It resets automatically after onSave / onDiscard.

import { useSettingsDirty } from "@/components/ui/anchored-settings-shell";

function NameField() {
  const markDirty = useSettingsDirty();
  return <input onChange={markDirty} />;
}

Scroll container

If the settings page lives inside a nested overflow-y-auto container, pass that container's ref to container and scroll-spy will use it as the scroll root. When omitted, the window is the scroll container.

Props

AnchoredSettingsShell

PropTypeDefaultDescription
childrenReactNodePut SettingsSection children here
dirtybooleanControlled: whether there are unsaved changes; when omitted, internal uncontrolled state is used
onSave() => void | Promise<void>Fires when Save is pressed; a loading state is shown while the Promise is pending
onDiscard() => voidFires when Discard is pressed
containerRefObject<HTMLElement | null>Ref to a nested scroll container, used as the scroll-spy root
dirtyMessagestring"你有尚未儲存的變更"Save-bar message text
saveLabelstring"儲存變更"Save button text
discardLabelstring"捨棄"Discard button text
navLabelstring"設定區塊"Accessible label for the anchor navigation

SettingsSection

PropTypeDefaultDescription
idstringThe section's anchor id; the nav item links to it
labelReactNodeSection title, also used as the nav item's text
iconLucideIconNav item icon
descriptionReactNodeExplanatory text below the title
childrenReactNodeSection content (form fields and so on)

SettingsRow

PropTypeDefaultDescription
labelReactNodeField label
hintReactNodeSupporting text below the label
htmlForstringThe label for binding to the control's id
childrenReactNodeThe control on the right

How it works

  • The navigation indicator's top / height are driven by useSpring, so it slides continuously between nav items as you change sections.
  • Scroll-spy samples each section's visible ratio with an IntersectionObserver and picks the most visible one as the current section; both the window and a nested container work as the scroll root.
  • When there are unsaved changes, the save bar at the bottom springs in; sticky bottom positioning keeps it flush with the bottom of the scroll viewport.

Accessibility

  • The navigation is a <nav> with anchor links, and the current section is marked aria-current="location"; clicking smooth-scrolls to the matching section.
  • While there are unsaved changes, ⌘/Ctrl+S triggers a save.
  • While saving, the buttons are disabled and a spinner is shown, preventing double submission.
  • With "reduce motion" enabled, the indicator and save bar position instantly and scrolling becomes auto, with no effect on layout or functionality.
  • Each section is tied to its heading with aria-labelledby, and toggle switches use role="switch" with aria-checked.

On this page