# Settings 01

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

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/settings-01).

- Page, with a live preview: https://shadcn.exchange/r/hirael/settings-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/settings-01-light-7d6ccba9ba17d689.png
- Registry item: https://hirael.com/r/settings-01.json
- Categories: blocks, saas
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/settings-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `alert-dialog`, `avatar`, `badge`, `button`, `field`, `input`, `input-group`, `select`, `textarea`

## Files

### `registry/hirael/bases/radix/blocks/settings-01/settings-01.tsx`

```tsx
// Settings 01 from Hirael <https://hirael.com/blocks/saas/settings-01>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { Check, CircleCheck, 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, AvatarImage } 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,
  FieldError,
  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';

const ENTER =
  'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const stagger = (index: number): React.CSSProperties => ({ animationDelay: `${index * 60}ms` });

type SettingsProps = React.ComponentProps<'div'>;

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;
  /** Shown under the control; also marks the row invalid. Pair it with `errorId` for the control's aria-describedby. */
  error?: React.ReactNode;
  errorId?: string;
  children?: React.ReactNode;
}

const SettingsRow = ({
  label,
  description,
  htmlFor,
  error,
  errorId,
  className,
  children,
  ...props
}: SettingsRowProps) => {
  return (
    <Field
      orientation="responsive"
      data-slot="settings-row"
      data-invalid={error ? true : undefined}
      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>{description}</FieldDescription> : null}
      </FieldContent>
      <div className="flex w-full flex-col gap-2 @md/field-group:shrink-0 @md/field-group:basis-80">
        {children}
        {error ? <FieldError id={errorId}>{error}</FieldError> : null}
      </div>
    </Field>
  );
};

type SettingsFooterProps = React.ComponentProps<'div'>;

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,
};

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: 'mohammadshehadeh',
  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 EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

interface ProfileErrors {
  name?: string;
  username?: string;
}

const validateProfile = (profile: typeof PROFILE): ProfileErrors => {
  const errors: ProfileErrors = {};
  if (!profile.name.trim()) errors.name = 'Enter your name.';
  if (!profile.username) errors.username = 'Pick a username.';

  return errors;
};

const validateRecovery = (email: string) => {
  if (email && !EMAIL_PATTERN.test(email)) return "That doesn't look like a valid email.";

  return undefined;
};

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 [profileAttempted, setProfileAttempted] = React.useState(false);
  const profileErrors = profileAttempted ? validateProfile(profile) : {};

  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;
  const [recoveryAttempted, setRecoveryAttempted] = React.useState(false);
  const recoveryError = recoveryAttempted ? validateRecovery(recovery) : undefined;

  const [deleteOpen, setDeleteOpen] = React.useState(false);
  const [deletionScheduled, setDeletionScheduled] = React.useState(false);

  const saveProfile = () => {
    setProfileAttempted(true);
    if (Object.keys(validateProfile(profile)).length > 0) return;
    setSavedProfile(profile);
    setProfileAttempted(false);
  };

  const saveRecovery = () => {
    setRecoveryAttempted(true);
    if (validateRecovery(recovery)) return;
    setSavedRecovery(recovery);
    setRecoveryAttempted(false);
  };

  const avatarInputRef = React.useRef<HTMLInputElement>(null);
  const [avatarUrl, setAvatarUrl] = React.useState<string | null>(null);

  React.useEffect(() => {
    return () => {
      if (avatarUrl) URL.revokeObjectURL(avatarUrl);
    };
  }, [avatarUrl]);

  const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) setAvatarUrl(URL.createObjectURL(file));
    event.target.value = '';
  };

  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={cn(ENTER, 'mb-8 flex flex-col gap-1')}>
          <span className="text-xs text-muted-foreground uppercase">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 style={stagger(1)} className={ENTER}>
            {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" style={stagger(2)} className={ENTER}>
              <SettingsSectionHeader>
                <SettingsSectionTitle>Profile</SettingsSectionTitle>
                <SettingsSectionDescription>
                  How you appear to teammates and in shared links.
                </SettingsSectionDescription>
              </SettingsSectionHeader>
              <FieldGroup className="gap-0">
                <SettingsRow label="Avatar" description="PNG or JPG, at least 256 by 256.">
                  <div className="flex items-center gap-3">
                    <Avatar key={avatarUrl ?? 'initials'} size="lg">
                      {avatarUrl ? <AvatarImage src={avatarUrl} alt="" className="object-cover" /> : null}
                      <AvatarFallback>{initialsOf(profile.name) || '?'}</AvatarFallback>
                    </Avatar>
                    <input
                      ref={avatarInputRef}
                      type="file"
                      accept="image/png,image/jpeg"
                      className="sr-only"
                      tabIndex={-1}
                      aria-hidden
                      onChange={handleAvatarChange}
                    />
                    <Button type="button" variant="outline" size="sm" onClick={() => avatarInputRef.current?.click()}>
                      Upload
                    </Button>
                    <Button
                      type="button"
                      variant="ghost"
                      size="sm"
                      disabled={!avatarUrl}
                      onClick={() => setAvatarUrl(null)}
                    >
                      Remove
                    </Button>
                  </div>
                </SettingsRow>
                <SettingsRow
                  label="Full name"
                  htmlFor="settings-name"
                  error={profileErrors.name}
                  errorId="settings-name-error"
                >
                  <Input
                    id="settings-name"
                    value={profile.name}
                    onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
                    autoComplete="name"
                    aria-invalid={profileErrors.name ? true : undefined}
                    aria-describedby={profileErrors.name ? 'settings-name-error' : undefined}
                  />
                </SettingsRow>
                <SettingsRow
                  label="Username"
                  description="Letters, numbers, and dashes only."
                  htmlFor="settings-username"
                  error={profileErrors.username}
                  errorId="settings-username-error"
                >
                  <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}
                      aria-invalid={profileErrors.username ? true : undefined}
                      aria-describedby={profileErrors.username ? 'settings-username-error' : undefined}
                    />
                  </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 text-xs text-muted-foreground tabular-nums">
                    {profile.bio.length} / {BIO_MAX}
                  </span>
                </SettingsRow>
              </FieldGroup>
              <SettingsFooter>
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  disabled={!profileDirty}
                  onClick={() => {
                    setProfile(savedProfile);
                    setProfileAttempted(false);
                  }}
                >
                  Cancel
                </Button>
                <Button type="button" size="sm" disabled={!profileDirty} onClick={saveProfile}>
                  Save changes
                </Button>
              </SettingsFooter>
            </SettingsSection>

            <SettingsSection id="email" style={stagger(3)} className={ENTER}>
              <SettingsSectionHeader>
                <SettingsSectionTitle>Email</SettingsSectionTitle>
                <SettingsSectionDescription>Where sign-in links, receipts, and alerts go.</SettingsSectionDescription>
              </SettingsSectionHeader>
              <FieldGroup className="gap-0">
                <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="hello@mohammadshehadeh.com"
                      autoComplete="email"
                      readOnly
                      className="flex-1"
                    />
                    <Badge variant="outline" className="shrink-0">
                      <Check aria-hidden />
                      Verified
                    </Badge>
                  </div>
                  <Button type="button" variant="link" size="sm" className="h-auto self-start">
                    Change email
                  </Button>
                </SettingsRow>
                <SettingsRow
                  label="Recovery email"
                  description="Used only if you lose access to your main address."
                  htmlFor="settings-recovery"
                  error={recoveryError}
                  errorId="settings-recovery-error"
                >
                  <Input
                    id="settings-recovery"
                    type="email"
                    placeholder="you@hirael.com"
                    value={recovery}
                    onChange={(e) => setRecovery(e.target.value)}
                    autoComplete="off"
                    aria-invalid={recoveryError ? true : undefined}
                    aria-describedby={recoveryError ? 'settings-recovery-error' : undefined}
                  />
                </SettingsRow>
              </FieldGroup>
              <SettingsFooter>
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  disabled={!recoveryDirty}
                  onClick={() => {
                    setRecovery(savedRecovery);
                    setRecoveryAttempted(false);
                  }}
                >
                  Cancel
                </Button>
                <Button type="button" size="sm" disabled={!recoveryDirty} onClick={saveRecovery}>
                  Save changes
                </Button>
              </SettingsFooter>
            </SettingsSection>

            <SettingsSection id="preferences" style={stagger(4)} className={ENTER}>
              <SettingsSectionHeader>
                <SettingsSectionTitle>Preferences</SettingsSectionTitle>
                <SettingsSectionDescription>Language, time zone, and calendar defaults.</SettingsSectionDescription>
              </SettingsSectionHeader>
              <FieldGroup className="gap-0">
                <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" size="sm" disabled={!prefsDirty} onClick={() => setSavedPrefs(prefs)}>
                  Save changes
                </Button>
              </SettingsFooter>
            </SettingsSection>

            <SettingsSection id="danger" destructive style={stagger(5)} className={ENTER}>
              <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"
              >
                {deletionScheduled ? (
                  <div className="flex flex-col gap-2 sm:items-end">
                    <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
                      <CircleCheck className="size-4 shrink-0 text-destructive" aria-hidden />
                      Deletion scheduled. Your account closes in 30 days.
                    </p>
                    <Button type="button" variant="outline" size="sm" onClick={() => setDeletionScheduled(false)}>
                      Cancel deletion
                    </Button>
                  </div>
                ) : null}
                <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
                  {deletionScheduled ? null : (
                    <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
                        variant="destructive"
                        onClick={() => {
                          setDeleteOpen(false);
                          setDeletionScheduled(true);
                        }}
                      >
                        Delete account
                      </AlertDialogAction>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </SettingsRow>
            </SettingsSection>
          </div>
        </Settings>
      </div>
    </section>
  );
};

export default Settings01;
```
