Account & Profile Settings
Profile form with avatar, handle preview and bio counter, a verified email row, notification switches, and a danger zone.
pnpm dlx shadcn@latest add @cosskit/settings-accountRequires the @cosskit namespace in your components.json.
Code
"use client";
import { useState } from "react";
import {
AtSignIcon,
BadgeCheckIcon,
KeyRoundIcon,
TrashIcon,
UploadIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardPanel,
CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
const TIMEZONES = [
{ label: "Pacific Time — Los Angeles", value: "america/los_angeles" },
{ label: "Mountain Time — Denver", value: "america/denver" },
{ label: "Central Time — Chicago", value: "america/chicago" },
{ label: "Eastern Time — New York", value: "america/new_york" },
{ label: "Greenwich Mean Time — London", value: "europe/london" },
{ label: "Central European Time — Berlin", value: "europe/berlin" },
];
const NOTIFICATIONS = [
{
id: "product",
label: "Product updates",
description: "New features, improvements, and changelog highlights.",
defaultOn: true,
},
{
id: "digest",
label: "Weekly digest",
description: "A Monday summary of workspace activity.",
defaultOn: false,
},
{
id: "security",
label: "Security alerts",
description: "Sign-ins from new devices and password changes.",
defaultOn: true,
// Security mail is not optional. Rendering the switch disabled-on is
// honest; hiding the row entirely makes people wonder if it exists.
locked: true,
},
];
const BIO_LIMIT = 160;
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function SettingsAccount() {
const [name, setName] = useState("Nadia Rahim");
const [handle, setHandle] = useState("nadia");
const [bio, setBio] = useState(
"Head of platform at Kestrel. Previously infrastructure at Northwind.",
);
const [timezone, setTimezone] = useState("america/los_angeles");
const [notifications, setNotifications] = useState(() =>
Object.fromEntries(NOTIFICATIONS.map((n) => [n.id, n.defaultOn])),
);
const bioRemaining = BIO_LIMIT - bio.length;
const bioOver = bioRemaining < 0;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 p-4 sm:p-6 md:p-8 [&_[data-slot=switch]]:cursor-pointer [&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
{/* Page heading */}
<div className="flex flex-col gap-1">
<h1 className="font-semibold text-2xl tracking-tight">Account</h1>
<p className="text-muted-foreground text-sm">
Update your profile, how you sign in, and what we email you about.
</p>
</div>
{/* Profile */}
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>
This is how you appear to other people in the workspace.
</CardDescription>
</CardHeader>
<CardPanel className="flex flex-col gap-6">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="size-16 shrink-0 rounded-full">
<AvatarFallback className="text-lg">NR</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline">
<UploadIcon />
Change photo
</Button>
<Button className="text-muted-foreground" size="sm" variant="ghost">
Remove
</Button>
</div>
<p className="text-muted-foreground text-xs">
JPG, PNG or GIF. 2 MB maximum.
</p>
</div>
</div>
<Separator />
<Field>
<FieldLabel>Full name</FieldLabel>
<Input onValueChange={setName} value={name} />
</Field>
<Field>
<FieldLabel>Username</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>
<AtSignIcon />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
onValueChange={(value) =>
setHandle(value.toLowerCase().replace(/[^a-z0-9-]/g, ""))
}
value={handle}
/>
</InputGroup>
<FieldDescription>
Your profile lives at kestrel.io/{handle || "…"}
</FieldDescription>
</Field>
<Field>
{/* `Field` is `flex flex-col items-start`, so a child row shrinks to
its content and `justify-between` has nothing to distribute.
`w-full` is what makes this row actually span the field. */}
<div className="flex w-full items-center justify-between gap-2">
<FieldLabel>Bio</FieldLabel>
<span
className={
bioOver
? "text-destructive-foreground text-xs tabular-nums"
: "text-muted-foreground text-xs tabular-nums"
}
>
{bioRemaining}
</span>
</div>
<Textarea
aria-invalid={bioOver || undefined}
onChange={(event) => setBio(event.target.value)}
value={bio}
/>
<FieldDescription>
A short introduction shown on your profile. Markdown is not
supported.
</FieldDescription>
</Field>
<Field>
<FieldLabel>Timezone</FieldLabel>
<Select
items={TIMEZONES}
onValueChange={(value) => setTimezone(value ?? timezone)}
value={timezone}
>
{/* Pointer cursor is a house rule; coss ships `cursor-default`
here by native-menu convention. It goes on the popup — which
portals out of this block, so no ancestor rule can reach it —
and never by editing coss source. */}
<SelectTrigger aria-label="Timezone" className="cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
{TIMEZONES.map((zone) => (
<SelectItem key={zone.value} value={zone.value}>
{zone.label}
</SelectItem>
))}
</SelectPopup>
</Select>
<FieldDescription>
Used for scheduled reports and activity timestamps.
</FieldDescription>
</Field>
</CardPanel>
<CardFooter className="flex justify-end gap-2 border-t">
<Button size="sm" variant="ghost">
Cancel
</Button>
<Button disabled={bioOver} size="sm">
Save changes
</Button>
</CardFooter>
</Card>
{/* Sign-in */}
<Card>
<CardHeader>
<CardTitle>Sign-in</CardTitle>
<CardDescription>
How you access your account. Changing your email requires
confirmation from both addresses.
</CardDescription>
</CardHeader>
<CardPanel className="flex flex-col gap-6">
<Field>
<div className="flex w-full flex-wrap items-center gap-2">
<FieldLabel>Email address</FieldLabel>
<Badge size="sm" variant="success">
<BadgeCheckIcon />
Verified
</Badge>
</div>
<Input defaultValue="nadia@kestrel.io" type="email" />
</Field>
<Separator />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<p className="font-medium text-sm">Password</p>
<p className="text-muted-foreground text-xs">
Last changed 4 months ago.
</p>
</div>
<Button size="sm" variant="outline">
<KeyRoundIcon />
Change password
</Button>
</div>
</CardPanel>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Email notifications</CardTitle>
<CardDescription>
Choose what lands in your inbox. This does not affect in-app
notifications.
</CardDescription>
</CardHeader>
<CardPanel className="flex flex-col gap-4">
{NOTIFICATIONS.map((item, index) => (
<div key={item.id}>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<label
className="font-medium text-sm"
htmlFor={`notify-${item.id}`}
>
{item.label}
</label>
<p className="text-muted-foreground text-xs">
{item.description}
{item.locked && " Always on."}
</p>
</div>
<Switch
checked={notifications[item.id]}
disabled={item.locked}
id={`notify-${item.id}`}
onCheckedChange={(checked) =>
setNotifications((current) => ({
...current,
[item.id]: checked,
}))
}
/>
</div>
{index < NOTIFICATIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</CardPanel>
</Card>
{/* Danger zone */}
<Card className="border-destructive/32">
<CardHeader>
<CardTitle>Delete account</CardTitle>
<CardDescription>
Permanently removes your account, your profile, and everything you
own in this workspace. Projects owned by the workspace stay put.
This cannot be undone.
</CardDescription>
</CardHeader>
<CardPanel>
<Button size="sm" variant="destructive-outline">
<TrashIcon />
Delete my account
</Button>
</CardPanel>
</Card>
</div>
);
}
Dependencies
- @coss/ui