Settings 1
Account settings page with a sticky section nav, card sections with label/control rows that stack on mobile, dirty-aware Save/Cancel footers, and a Danger zone with an alert-dialog confirm. Ships composable parts.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/settings-01.jsonCode
components/blocks/settings-01.tsx
'use client';
import * as React from 'react';
import { Check, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/registry/hirael/bases/radix/ui/alert-dialog';
import { Avatar, AvatarFallback } from '@/registry/hirael/bases/radix/ui/avatar';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from '@/registry/hirael/bases/radix/ui/field';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/hirael/bases/radix/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { Textarea } from '@/registry/hirael/bases/radix/ui/textarea';
/* -------------------------------------------------------------------------- */
/* Parts */
/* -------------------------------------------------------------------------- */
type SettingsProps = React.ComponentProps<'div'>;
/** Page container: side nav on md+, stacked on mobile. */
const Settings = ({ className, ...props }: SettingsProps) => {
return (
<div
data-slot="settings"
className={cn('grid w-full gap-6 md:grid-cols-[11rem_minmax(0,1fr)] md:gap-10', className)}
{...props}
/>
);
};
type SettingsNavProps = React.ComponentProps<'nav'>;
const SettingsNav = ({ className, children, ...props }: SettingsNavProps) => {
return (
<nav
data-slot="settings-nav"
aria-label="Settings sections"
className={cn('md:sticky md:top-6 md:self-start', className)}
{...props}
>
<ul className="-mx-4 flex gap-1 overflow-x-auto px-4 pb-1 md:mx-0 md:flex-col md:overflow-visible md:px-0 md:pb-0">
{children}
</ul>
</nav>
);
};
interface SettingsNavItemProps extends React.ComponentProps<'a'> {
active?: boolean;
}
const SettingsNavItem = ({ active = false, className, ...props }: SettingsNavItemProps) => {
return (
<li data-slot="settings-nav-item" className="shrink-0">
<a
data-active={active || undefined}
aria-current={active ? 'page' : undefined}
className={cn(
'flex items-center rounded-md px-2.5 py-1.5 text-sm whitespace-nowrap transition-colors outline-none',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
active
? 'bg-accent font-medium text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
className,
)}
{...props}
/>
</li>
);
};
interface SettingsSectionProps extends React.ComponentProps<'section'> {
/** Paints the border in the destructive tone for irreversible actions. */
destructive?: boolean;
}
const SettingsSection = ({ destructive = false, className, ...props }: SettingsSectionProps) => {
return (
<section
data-slot="settings-section"
data-destructive={destructive || undefined}
className={cn(
'scroll-mt-6 overflow-hidden rounded-lg border bg-card text-card-foreground',
destructive ? 'border-destructive/40' : 'border-border',
className,
)}
{...props}
/>
);
};
type SettingsSectionHeaderProps = React.ComponentProps<'div'>;
const SettingsSectionHeader = ({ className, ...props }: SettingsSectionHeaderProps) => {
return (
<div
data-slot="settings-section-header"
className={cn('flex flex-col gap-1 border-b border-border px-5 py-4', className)}
{...props}
/>
);
};
type SettingsSectionTitleProps = React.ComponentProps<'h2'>;
const SettingsSectionTitle = ({ className, ...props }: SettingsSectionTitleProps) => {
return (
<h2
data-slot="settings-section-title"
className={cn('text-base font-semibold tracking-[-0.01em] text-foreground', className)}
{...props}
/>
);
};
type SettingsSectionDescriptionProps = React.ComponentProps<'p'>;
const SettingsSectionDescription = ({ className, ...props }: SettingsSectionDescriptionProps) => {
return (
<p data-slot="settings-section-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
);
};
interface SettingsRowProps extends Omit<React.ComponentProps<'div'>, 'children'> {
label: React.ReactNode;
description?: React.ReactNode;
/** Connects the label to the control so clicking it focuses the field. */
htmlFor?: string;
children?: React.ReactNode;
}
/**
* Label and description on the start side, control on the end side.
* Stacks until the surrounding `FieldGroup` is wide enough for a row.
*/
const SettingsRow = ({ label, description, htmlFor, className, children, ...props }: SettingsRowProps) => {
return (
<Field
orientation="responsive"
data-slot="settings-row"
className={cn('gap-3 px-5 py-4 @md/field-group:justify-between @md/field-group:gap-8', className)}
{...props}
>
<FieldContent className="min-w-0 gap-0.5 @md/field-group:max-w-xs @md/field-group:pt-1.5">
{htmlFor ? <FieldLabel htmlFor={htmlFor}>{label}</FieldLabel> : <FieldTitle>{label}</FieldTitle>}
{description ? <FieldDescription className="text-xs">{description}</FieldDescription> : null}
</FieldContent>
<div className="flex w-full flex-col gap-2 @md/field-group:shrink-0 @md/field-group:basis-80">{children}</div>
</Field>
);
};
type SettingsFooterProps = React.ComponentProps<'div'>;
/** Save / Cancel row at the bottom of a section. */
const SettingsFooter = ({ className, ...props }: SettingsFooterProps) => {
return (
<div
data-slot="settings-footer"
className={cn('flex items-center justify-end gap-2 border-t border-border bg-muted/30 px-5 py-3', className)}
{...props}
/>
);
};
export {
Settings,
SettingsNav,
SettingsNavItem,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsRow,
SettingsFooter,
};
/* -------------------------------------------------------------------------- */
/* Preview */
/* -------------------------------------------------------------------------- */
const SECTIONS = [
{ id: 'profile', label: 'Profile' },
{ id: 'email', label: 'Email' },
{ id: 'preferences', label: 'Preferences' },
{ id: 'danger', label: 'Danger zone' },
] as const;
type SectionId = (typeof SECTIONS)[number]['id'];
const LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'ar', label: 'Arabic' },
{ value: 'de', label: 'German' },
{ value: 'fr', label: 'French' },
{ value: 'ja', label: 'Japanese' },
];
const TIMEZONES = [
{ value: 'Asia/Amman', label: 'Amman (GMT+3)' },
{ value: 'Europe/Berlin', label: 'Berlin (GMT+2)' },
{ value: 'Europe/London', label: 'London (GMT+1)' },
{ value: 'America/New_York', label: 'New York (GMT-4)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (GMT-7)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (GMT+9)' },
];
const WEEK_STARTS = [
{ value: 'monday', label: 'Monday' },
{ value: 'saturday', label: 'Saturday' },
{ value: 'sunday', label: 'Sunday' },
];
const PROFILE = {
name: 'Mohammad Shehadeh',
username: 'mohammad',
bio: 'Building Hirael, a registry of the parts shadcn/ui leaves out.',
};
const PREFERENCES = {
language: 'en',
timezone: 'Asia/Amman',
weekStart: 'monday',
};
const BIO_MAX = 160;
const initialsOf = (name: string) => {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
};
const Settings01 = () => {
const [active, setActive] = React.useState<SectionId>('profile');
const [profile, setProfile] = React.useState(PROFILE);
const [savedProfile, setSavedProfile] = React.useState(PROFILE);
const profileDirty =
profile.name !== savedProfile.name ||
profile.username !== savedProfile.username ||
profile.bio !== savedProfile.bio;
const [prefs, setPrefs] = React.useState(PREFERENCES);
const [savedPrefs, setSavedPrefs] = React.useState(PREFERENCES);
const prefsDirty =
prefs.language !== savedPrefs.language ||
prefs.timezone !== savedPrefs.timezone ||
prefs.weekStart !== savedPrefs.weekStart;
const [recovery, setRecovery] = React.useState('');
const [savedRecovery, setSavedRecovery] = React.useState('');
const recoveryDirty = recovery !== savedRecovery;
return (
<section data-slot="settings-01-block" className="min-h-svh w-full bg-background">
<div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-8 flex flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">Account</span>
<h1 className="text-2xl font-semibold tracking-[-0.02em]">Account settings</h1>
<p className="text-sm text-muted-foreground">Your profile, contact email, and how the app behaves for you.</p>
</div>
<Settings>
<SettingsNav>
{SECTIONS.map((section) => (
<SettingsNavItem
key={section.id}
href={`#${section.id}`}
active={active === section.id}
onClick={() => setActive(section.id)}
className={
section.id === 'danger' && active !== section.id
? 'text-destructive/80 hover:text-destructive'
: undefined
}
>
{section.label}
</SettingsNavItem>
))}
</SettingsNav>
<div className="flex min-w-0 flex-col gap-6">
<SettingsSection id="profile">
<SettingsSectionHeader>
<SettingsSectionTitle>Profile</SettingsSectionTitle>
<SettingsSectionDescription>
How you appear to teammates and in shared links.
</SettingsSectionDescription>
</SettingsSectionHeader>
<FieldGroup className="gap-0 divide-y divide-border">
<SettingsRow label="Avatar" description="PNG or JPG, at least 256 by 256.">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarFallback className="font-mono text-xs font-medium text-foreground">
{initialsOf(profile.name) || '?'}
</AvatarFallback>
</Avatar>
<Button type="button" variant="outline" size="sm">
Upload
</Button>
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground">
Remove
</Button>
</div>
</SettingsRow>
<SettingsRow label="Full name" htmlFor="settings-name">
<Input
id="settings-name"
value={profile.name}
onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
autoComplete="name"
/>
</SettingsRow>
<SettingsRow
label="Username"
description="Letters, numbers, and dashes only."
htmlFor="settings-username"
>
<InputGroup>
<InputGroupAddon align="inline-start">
<span className="text-muted-foreground">hirael.com/</span>
</InputGroupAddon>
<InputGroupInput
id="settings-username"
value={profile.username}
onChange={(e) =>
setProfile((p) => ({
...p,
username: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''),
}))
}
autoComplete="username"
spellCheck={false}
/>
</InputGroup>
</SettingsRow>
<SettingsRow
label="Bio"
description="A sentence or two. Shown on your public profile."
htmlFor="settings-bio"
>
<Textarea
id="settings-bio"
value={profile.bio}
maxLength={BIO_MAX}
rows={3}
onChange={(e) => setProfile((p) => ({ ...p, bio: e.target.value }))}
/>
<span className="self-end font-mono text-[10px] tabular-nums text-muted-foreground">
{profile.bio.length} / {BIO_MAX}
</span>
</SettingsRow>
</FieldGroup>
<SettingsFooter>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!profileDirty}
onClick={() => setProfile(savedProfile)}
>
Cancel
</Button>
<Button
type="button"
variant={profileDirty ? 'default' : 'outline'}
size="sm"
onClick={() => setSavedProfile(profile)}
>
Save changes
</Button>
</SettingsFooter>
</SettingsSection>
<SettingsSection id="email">
<SettingsSectionHeader>
<SettingsSectionTitle>Email</SettingsSectionTitle>
<SettingsSectionDescription>Where sign-in links, receipts, and alerts go.</SettingsSectionDescription>
</SettingsSectionHeader>
<FieldGroup className="gap-0 divide-y divide-border">
<SettingsRow
label="Email address"
description="Changing it sends a confirmation to the new address."
htmlFor="settings-email"
>
<div className="flex items-center gap-2">
<Input
id="settings-email"
type="email"
defaultValue="mohammad@plinth.dev"
autoComplete="email"
readOnly
className="flex-1"
/>
<Badge variant="outline" className="shrink-0 gap-1 text-success">
<Check aria-hidden />
Verified
</Badge>
</div>
<Button type="button" variant="link" size="sm" className="h-auto self-start p-0 text-xs">
Change email
</Button>
</SettingsRow>
<SettingsRow
label="Recovery email"
description="Used only if you lose access to your main address."
htmlFor="settings-recovery"
>
<Input
id="settings-recovery"
type="email"
placeholder="you@personal.com"
value={recovery}
onChange={(e) => setRecovery(e.target.value)}
autoComplete="off"
/>
</SettingsRow>
</FieldGroup>
<SettingsFooter>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!recoveryDirty}
onClick={() => setRecovery(savedRecovery)}
>
Cancel
</Button>
<Button
type="button"
variant={recoveryDirty ? 'default' : 'outline'}
size="sm"
onClick={() => setSavedRecovery(recovery)}
>
Save changes
</Button>
</SettingsFooter>
</SettingsSection>
<SettingsSection id="preferences">
<SettingsSectionHeader>
<SettingsSectionTitle>Preferences</SettingsSectionTitle>
<SettingsSectionDescription>Language, time zone, and calendar defaults.</SettingsSectionDescription>
</SettingsSectionHeader>
<FieldGroup className="gap-0 divide-y divide-border">
<SettingsRow
label="Language"
description="The interface language. Content is not translated."
htmlFor="settings-language"
>
<Select value={prefs.language} onValueChange={(language) => setPrefs((p) => ({ ...p, language }))}>
<SelectTrigger id="settings-language" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow
label="Time zone"
description="Times and reminders are shown in this zone."
htmlFor="settings-timezone"
>
<Select value={prefs.timezone} onValueChange={(timezone) => setPrefs((p) => ({ ...p, timezone }))}>
<SelectTrigger id="settings-timezone" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow label="Week starts on" htmlFor="settings-week">
<Select value={prefs.weekStart} onValueChange={(weekStart) => setPrefs((p) => ({ ...p, weekStart }))}>
<SelectTrigger id="settings-week" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEEK_STARTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
</FieldGroup>
<SettingsFooter>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!prefsDirty}
onClick={() => setPrefs(savedPrefs)}
>
Cancel
</Button>
<Button
type="button"
variant={prefsDirty ? 'default' : 'outline'}
size="sm"
onClick={() => setSavedPrefs(prefs)}
>
Save changes
</Button>
</SettingsFooter>
</SettingsSection>
<SettingsSection id="danger" destructive>
<SettingsSectionHeader>
<SettingsSectionTitle className="flex items-center gap-2">
<TriangleAlert className="size-4 text-destructive" aria-hidden />
Danger zone
</SettingsSectionTitle>
<SettingsSectionDescription>These actions cannot be undone. Take a moment.</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsRow
label="Delete account"
description="Removes your profile, workspaces you own, and all data within 30 days."
className="sm:items-center"
>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" size="sm" className="sm:self-end">
Delete account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes{' '}
<span className="font-medium text-foreground">{savedProfile.name}</span> and every workspace you
own. Teammates lose access immediately. There is no way back.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep account</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60">
Delete account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsRow>
</SettingsSection>
</div>
</Settings>
</div>
</section>
);
};
export default Settings01;
Dependencies
shadcn registry
alert-dialogavatarbadgebuttonfieldinputinput-groupselecttextarea
npm
lucide-react
Shipped