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.
npx shadcn@latest add https://webberui.com/r/anchored-settings-shell.jsonPlayground
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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Put SettingsSection children here |
dirty | boolean | — | Controlled: 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 | () => void | — | Fires when Discard is pressed |
container | RefObject<HTMLElement | null> | — | Ref to a nested scroll container, used as the scroll-spy root |
dirtyMessage | string | "你有尚未儲存的變更" | Save-bar message text |
saveLabel | string | "儲存變更" | Save button text |
discardLabel | string | "捨棄" | Discard button text |
navLabel | string | "設定區塊" | Accessible label for the anchor navigation |
SettingsSection
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | The section's anchor id; the nav item links to it |
label | ReactNode | — | Section title, also used as the nav item's text |
icon | LucideIcon | — | Nav item icon |
description | ReactNode | — | Explanatory text below the title |
children | ReactNode | — | Section content (form fields and so on) |
SettingsRow
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Field label |
hint | ReactNode | — | Supporting text below the label |
htmlFor | string | — | The label for binding to the control's id |
children | ReactNode | — | The control on the right |
How it works
- The navigation indicator's
top/heightare driven byuseSpring, so it slides continuously between nav items as you change sections. - Scroll-spy samples each section's visible ratio with an
IntersectionObserverand 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 bottompositioning keeps it flush with the bottom of the scroll viewport.
Accessibility
- The navigation is a
<nav>with anchor links, and the current section is markedaria-current="location"; clicking smooth-scrolls to the matching section. - While there are unsaved changes,
⌘/Ctrl+Striggers 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 userole="switch"witharia-checked.
Docked Inspector Rail
A contextual inspector panel docked to the right that expands by pushing, so the main content reflows with it; sections collapse and the width can be dragged.
Setup Checklist Rail
An onboarding checklist pinned to the side of the app — completed items check off and collapse, the progress ring at the top counts up, and once everything is done it shrinks into an expandable floating badge.