v4.2.0
- Saved views now sync across devices and can be pinned to the sidebar.
- CSV exports include custom fields and respect the active filters.
- Faster dashboard loads: median time to first chart dropped from 1.8 s to 0.9 s.
Free, copy-and-go Scroll Area components built on the SevenUI Scroll Area primitive.Read the primitive docs.
Press ? anywhere to open this sheet.
"use client";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { ScrollArea } from "@/components/ui/scroll-area";
const groups = [
{
name: "Navigation",
shortcuts: [
{ action: "Open command menu", keys: ["⌘", "K"] },
{ action: "Go to inbox", keys: ["G", "I"] },
{ action: "Go to projects", keys: ["G", "P"] },
{ action: "Search in current view", keys: ["/"] },
],
},
{
name: "Issues",
shortcuts: [
{ action: "Create issue", keys: ["C"] },
{ action: "Assign to me", keys: ["I"] },
{ action: "Change status", keys: ["S"] },
{ action: "Set priority", keys: ["P"] },
{ action: "Add label", keys: ["L"] },
],
},
{
name: "Editing",
shortcuts: [
{ action: "Bold", keys: ["⌘", "B"] },
{ action: "Insert link", keys: ["⌘", "K"] },
{ action: "Code block", keys: ["⌘", "⇧", "C"] },
{ action: "Submit comment", keys: ["⌘", "Enter"] },
],
},
{
name: "View",
shortcuts: [
{ action: "Toggle sidebar", keys: ["["] },
{ action: "Toggle dark mode", keys: ["⌘", "⇧", "L"] },
{ action: "Show this sheet", keys: ["?"] },
],
},
];
export default function ScrollArea01() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex flex-col gap-1">
<h3 id="scroll-area-01-title" className="text-sm font-medium">
Keyboard shortcuts
</h3>
<p className="text-sm text-muted-foreground">
Press <Kbd>?</Kbd> anywhere to open this sheet.
</p>
</div>
<ScrollArea
role="region"
aria-labelledby="scroll-area-01-title"
className="h-72 rounded-lg border bg-background"
>
<div className="flex flex-col gap-5 p-4 pr-5">
{groups.map((group) => (
<section
key={group.name}
aria-labelledby={`scroll-area-01-${group.name.toLowerCase()}`}
className="flex flex-col gap-2"
>
<h4
id={`scroll-area-01-${group.name.toLowerCase()}`}
className="text-xs font-medium text-muted-foreground"
>
{group.name}
</h4>
<dl className="flex flex-col gap-2">
{group.shortcuts.map((shortcut) => (
<div
key={shortcut.action}
className="flex items-center justify-between gap-3 text-sm"
>
<dt className="min-w-0 truncate">{shortcut.action}</dt>
<dd className="shrink-0">
<KbdGroup>
{shortcut.keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</dd>
</div>
))}
</dl>
</section>
))}
</div>
</ScrollArea>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-01pnpm dlx shadcn@latest add @sevenui/component/scroll-area-01yarn dlx shadcn@latest add @sevenui/component/scroll-area-01bunx --bun shadcn@latest add @sevenui/component/scroll-area-01Showing 41 integrations in 2 categories
"use client";
import * as React from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Toggle } from "@/components/ui/toggle";
const categories = [
{ value: "analytics", label: "Analytics", count: 18 },
{ value: "billing", label: "Billing", count: 9 },
{ value: "crm", label: "CRM", count: 14 },
{ value: "design", label: "Design", count: 11 },
{ value: "devops", label: "DevOps", count: 23 },
{ value: "email", label: "Email", count: 7 },
{ value: "messaging", label: "Messaging", count: 12 },
{ value: "payments", label: "Payments", count: 6 },
{ value: "security", label: "Security", count: 15 },
{ value: "storage", label: "Storage", count: 8 },
];
export default function ScrollArea02() {
const [selected, setSelected] = React.useState<string[]>([
"analytics",
"devops",
]);
const total = categories
.filter((category) => selected.includes(category.value))
.reduce((sum, category) => sum + category.count, 0);
function toggle(value: string, pressed: boolean) {
setSelected((current) =>
pressed
? [...current, value]
: current.filter((item) => item !== value),
);
}
return (
<div className="flex w-full max-w-md flex-col gap-2">
<span id="scroll-area-02-label" className="text-sm font-medium">
Filter integrations
</span>
<ScrollArea orientation="horizontal" className="w-full">
<fieldset
aria-labelledby="scroll-area-02-label"
className="m-0 flex w-max min-w-0 gap-1.5 border-0 p-0 pb-3"
>
{categories.map((category) => (
<Toggle
key={category.value}
variant="outline"
size="sm"
pressed={selected.includes(category.value)}
onPressedChange={(pressed) => toggle(category.value, pressed)}
className="rounded-full aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
{category.label}
<span className="text-xs tabular-nums opacity-70">
{category.count}
</span>
</Toggle>
))}
</fieldset>
</ScrollArea>
<p className="text-sm text-muted-foreground" aria-live="polite">
{selected.length === 0
? "Showing all 123 integrations"
: `Showing ${total} integrations in ${selected.length} ${
selected.length === 1 ? "category" : "categories"
}`}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-02pnpm dlx shadcn@latest add @sevenui/component/scroll-area-02yarn dlx shadcn@latest add @sevenui/component/scroll-area-02bunx --bun shadcn@latest add @sevenui/component/scroll-area-02"use client";
import * as React from "react";
import { MessageSquare, Plus } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
type Task = {
id: string;
title: string;
label: string;
assignee: string;
comments: number;
};
const initialColumns: { id: string; name: string; tasks: Task[] }[] = [
{
id: "backlog",
name: "Backlog",
tasks: [
{
id: "WEB-212",
title: "Add annual billing toggle to pricing page",
label: "Growth",
assignee: "MC",
comments: 2,
},
{
id: "WEB-219",
title: "Audit empty states across the dashboard",
label: "Design",
assignee: "PR",
comments: 0,
},
{
id: "WEB-224",
title: "Support SCIM provisioning for Okta",
label: "Enterprise",
assignee: "DO",
comments: 5,
},
{
id: "WEB-231",
title: "Lazy-load chart bundles on the reports view",
label: "Performance",
assignee: "LM",
comments: 1,
},
{
id: "WEB-236",
title: "Rewrite password reset emails",
label: "Copy",
assignee: "SA",
comments: 3,
},
],
},
{
id: "in-progress",
name: "In progress",
tasks: [
{
id: "WEB-198",
title: "Migrate invoices to usage-based line items",
label: "Billing",
assignee: "NF",
comments: 8,
},
{
id: "WEB-205",
title: "Keyboard navigation for the command menu",
label: "Accessibility",
assignee: "HS",
comments: 4,
},
{
id: "WEB-208",
title: "Show seat usage on the team settings page",
label: "Growth",
assignee: "MC",
comments: 1,
},
],
},
{
id: "in-review",
name: "In review",
tasks: [
{
id: "WEB-187",
title: "Fix invoice PDF margins in Safari",
label: "Bug",
assignee: "LM",
comments: 6,
},
{
id: "WEB-190",
title: "Dark mode contrast pass on charts",
label: "Design",
assignee: "PR",
comments: 2,
},
],
},
{
id: "done",
name: "Done",
tasks: [
{
id: "WEB-171",
title: "Rate-limit the public status API",
label: "Platform",
assignee: "DO",
comments: 3,
},
{
id: "WEB-176",
title: "Onboarding checklist v2",
label: "Growth",
assignee: "SA",
comments: 11,
},
{
id: "WEB-180",
title: "Remove legacy v2 reporting endpoints",
label: "Platform",
assignee: "NF",
comments: 0,
},
{
id: "WEB-183",
title: "Add SAML single sign-on",
label: "Enterprise",
assignee: "HS",
comments: 7,
},
],
},
];
export default function ScrollArea03() {
const [columns, setColumns] = React.useState(initialColumns);
const nextId = React.useRef(240);
// New tasks land at the top of their column, which scrolls up to show them.
function addTask(columnId: string, list: HTMLElement | null) {
const id = `WEB-${nextId.current++}`;
setColumns((current) =>
current.map((column) =>
column.id === columnId
? {
...column,
tasks: [
{
id,
title: "Untitled task",
label: "New",
assignee: "ME",
comments: 0,
},
...column.tasks,
],
}
: column,
),
);
list
?.closest<HTMLElement>('[data-slot="scroll-area-viewport"]')
?.scrollTo({ top: 0 });
}
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-baseline justify-between gap-2">
<h3 id="scroll-area-03-title" className="text-sm font-medium">
Sprint 42 · Web app
</h3>
<span className="text-xs text-muted-foreground">Ends Oct 9</span>
</div>
{/* The board scrolls sideways; each column scrolls on its own. */}
<ScrollArea
orientation="horizontal"
role="region"
aria-labelledby="scroll-area-03-title"
className="w-full rounded-xl border bg-muted/40"
>
<div className="flex w-max gap-3 p-3 pb-4">
{columns.map((column) => (
<section
key={column.id}
aria-labelledby={`scroll-area-03-${column.id}`}
className="flex w-60 shrink-0 flex-col gap-2"
>
<div className="flex items-center justify-between gap-2 px-1">
<h4
id={`scroll-area-03-${column.id}`}
className="flex items-center gap-2 text-sm font-medium"
>
{column.name}
<span className="text-xs font-normal text-muted-foreground tabular-nums">
{column.tasks.length}
</span>
</h4>
<Button
variant="ghost"
size="icon-xs"
aria-label={`Add task to ${column.name}`}
onClick={(event) =>
addTask(
column.id,
event.currentTarget
.closest("section")
?.querySelector("ul") ?? null,
)
}
>
<Plus aria-hidden="true" />
</Button>
</div>
<ScrollArea
aria-label={`${column.name} tasks`}
className="h-72"
>
<ul className="flex flex-col gap-2 pr-3">
{column.tasks.map((task) => (
<li
key={task.id}
className="flex flex-col gap-3 rounded-lg border bg-card p-3 text-card-foreground shadow-xs"
>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground tabular-nums">
{task.id}
</span>
<p className="text-sm leading-snug font-medium text-pretty">
{task.title}
</p>
</div>
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary">{task.label}</Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{task.comments > 0 && (
<span className="flex items-center gap-1 tabular-nums">
<MessageSquare
aria-hidden="true"
className="size-3.5"
/>
{task.comments}
<span className="sr-only"> comments</span>
</span>
)}
<Avatar size="sm">
<AvatarFallback>{task.assignee}</AvatarFallback>
</Avatar>
</div>
</div>
</li>
))}
</ul>
</ScrollArea>
</section>
))}
</div>
</ScrollArea>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-03pnpm dlx shadcn@latest add @sevenui/component/scroll-area-03yarn dlx shadcn@latest add @sevenui/component/scroll-area-03bunx --bun shadcn@latest add @sevenui/component/scroll-area-03"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
const regions = [
{
name: "Americas",
zones: [
{ id: "America/Los_Angeles", city: "Los Angeles", offset: "UTC−07:00" },
{ id: "America/Denver", city: "Denver", offset: "UTC−06:00" },
{ id: "America/Chicago", city: "Chicago", offset: "UTC−05:00" },
{ id: "America/New_York", city: "New York", offset: "UTC−04:00" },
{ id: "America/Sao_Paulo", city: "São Paulo", offset: "UTC−03:00" },
],
},
{
name: "Europe",
zones: [
{ id: "Europe/London", city: "London", offset: "UTC+01:00" },
{ id: "Europe/Berlin", city: "Berlin", offset: "UTC+02:00" },
{ id: "Europe/Paris", city: "Paris", offset: "UTC+02:00" },
{ id: "Europe/Istanbul", city: "Istanbul", offset: "UTC+03:00" },
],
},
{
name: "Asia",
zones: [
{ id: "Asia/Dubai", city: "Dubai", offset: "UTC+04:00" },
{ id: "Asia/Kolkata", city: "Mumbai", offset: "UTC+05:30" },
{ id: "Asia/Singapore", city: "Singapore", offset: "UTC+08:00" },
{ id: "Asia/Tokyo", city: "Tokyo", offset: "UTC+09:00" },
],
},
{
name: "Oceania",
zones: [
{ id: "Australia/Sydney", city: "Sydney", offset: "UTC+10:00" },
{ id: "Pacific/Auckland", city: "Auckland", offset: "UTC+12:00" },
],
},
];
export default function ScrollArea04() {
const [selected, setSelected] = React.useState("Europe/Berlin");
const current = regions
.flatMap((region) => region.zones)
.find((zone) => zone.id === selected);
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<span id="scroll-area-04-label" className="text-sm font-medium">
Time zone
</span>
<span className="truncate text-xs text-muted-foreground tabular-nums">
{current?.city} · {current?.offset}
</span>
</div>
<ScrollArea
role="region"
aria-labelledby="scroll-area-04-label"
className="h-72 rounded-lg border bg-popover text-popover-foreground"
>
{regions.map((region) => (
<section
key={region.name}
aria-labelledby={`scroll-area-04-${region.name.toLowerCase()}`}
>
<h4
id={`scroll-area-04-${region.name.toLowerCase()}`}
className="sticky top-0 z-10 border-b bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground"
>
{region.name}
</h4>
<ul className="p-1">
{region.zones.map((zone) => {
const isSelected = zone.id === selected;
return (
<li key={zone.id}>
<button
type="button"
aria-pressed={isSelected}
onClick={() => setSelected(zone.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 aria-pressed:font-medium"
>
<Check
aria-hidden="true"
className={
isSelected
? "size-4 shrink-0"
: "size-4 shrink-0 opacity-0"
}
/>
<span className="flex-1 truncate">{zone.city}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{zone.offset}
</span>
</button>
</li>
);
})}
</ul>
</section>
))}
</ScrollArea>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-04pnpm dlx shadcn@latest add @sevenui/component/scroll-area-04yarn dlx shadcn@latest add @sevenui/component/scroll-area-04bunx --bun shadcn@latest add @sevenui/component/scroll-area-04"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
const columns = [
"Region",
"Requests",
"p50",
"p95",
"p99",
"Error rate",
"Cache hit",
"Bandwidth",
];
const rows = [
[
"us-east-1",
"4.82M",
"38 ms",
"112 ms",
"240 ms",
"0.04%",
"93.1%",
"1.9 TB",
],
[
"us-west-2",
"3.17M",
"41 ms",
"126 ms",
"268 ms",
"0.06%",
"91.4%",
"1.2 TB",
],
[
"eu-west-1",
"2.94M",
"44 ms",
"131 ms",
"281 ms",
"0.05%",
"92.7%",
"1.1 TB",
],
[
"eu-central-1",
"2.21M",
"46 ms",
"139 ms",
"302 ms",
"0.09%",
"90.2%",
"840 GB",
],
[
"ap-south-1",
"1.88M",
"63 ms",
"188 ms",
"410 ms",
"0.21%",
"86.5%",
"702 GB",
],
[
"ap-southeast-1",
"1.64M",
"57 ms",
"171 ms",
"366 ms",
"0.12%",
"88.9%",
"615 GB",
],
[
"ap-northeast-1",
"1.52M",
"52 ms",
"158 ms",
"329 ms",
"0.08%",
"89.8%",
"580 GB",
],
[
"sa-east-1",
"0.96M",
"71 ms",
"204 ms",
"455 ms",
"0.18%",
"84.3%",
"362 GB",
],
[
"ca-central-1",
"0.81M",
"40 ms",
"119 ms",
"252 ms",
"0.03%",
"92.2%",
"298 GB",
],
[
"af-south-1",
"0.37M",
"88 ms",
"246 ms",
"521 ms",
"0.33%",
"80.6%",
"141 GB",
],
[
"me-central-1",
"0.29M",
"79 ms",
"221 ms",
"478 ms",
"0.26%",
"82.1%",
"109 GB",
],
];
export default function ScrollArea05() {
return (
<div className="flex w-full max-w-lg flex-col gap-2">
<h3 id="scroll-area-05-title" className="text-sm font-medium">
Edge latency, last 24 hours
</h3>
<ScrollArea
orientation="both"
role="region"
aria-labelledby="scroll-area-05-title"
className="h-72 w-full rounded-lg border bg-card"
>
<table className="w-max min-w-full border-separate border-spacing-0 text-sm">
<thead>
<tr>
{columns.map((column, index) => (
<th
key={column}
scope="col"
className={
index === 0
? "sticky top-0 left-0 z-20 border-r border-b bg-muted px-3 py-2 text-left font-medium"
: "sticky top-0 z-10 border-b bg-muted px-3 py-2 text-right font-medium whitespace-nowrap"
}
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(([region, ...values]) => (
<tr key={region} className="group">
<th
scope="row"
className="sticky left-0 z-10 border-r border-b bg-card px-3 py-2 text-left font-mono text-xs font-normal group-last:border-b-0 group-hover:bg-muted"
>
{region}
</th>
{values.map((value, index) => (
<td
key={columns[index + 1]}
className="border-b px-3 py-2 text-right whitespace-nowrap tabular-nums group-last:border-b-0 group-hover:bg-muted/60"
>
{value}
</td>
))}
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-05pnpm dlx shadcn@latest add @sevenui/component/scroll-area-05yarn dlx shadcn@latest add @sevenui/component/scroll-area-05bunx --bun shadcn@latest add @sevenui/component/scroll-area-05"use client";
import * as React from "react";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
const releases = [
{
version: "4.2.0",
date: "Sep 18, 2026",
notes: [
"Saved views now sync across devices and can be pinned to the sidebar.",
"CSV exports include custom fields and respect the active filters.",
"Faster dashboard loads: median time to first chart dropped from 1.8 s to 0.9 s.",
],
},
{
version: "4.1.3",
date: "Sep 4, 2026",
notes: [
"Fixed a timezone bug that shifted weekly reports by one day for UTC+13 workspaces.",
"Webhook retries now back off exponentially up to six attempts.",
],
},
{
version: "4.1.0",
date: "Aug 21, 2026",
notes: [
"SAML single sign-on is available on the Scale plan.",
"Audit log entries can be streamed to your own S3 bucket.",
"Keyboard shortcut sheet opens with the question mark key.",
],
},
{
version: "4.0.2",
date: "Aug 7, 2026",
notes: [
"Chart tooltips no longer clip at the right edge of narrow cards.",
"Improved screen reader labels on the date range picker.",
],
},
{
version: "4.0.0",
date: "Jul 24, 2026",
notes: [
"New workspace navigation with collapsible sections.",
"Dark mode follows your system setting by default.",
"The legacy v2 reporting API is retired; migrate to v3 endpoints.",
],
},
];
export default function ScrollArea06() {
const contentRef = React.useRef<HTMLDivElement>(null);
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
const viewport = contentRef.current?.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
if (!viewport) return;
function update() {
if (!viewport) return;
const max = viewport.scrollHeight - viewport.clientHeight;
setProgress(max > 0 ? Math.round((viewport.scrollTop / max) * 100) : 0);
}
update();
viewport.addEventListener("scroll", update, { passive: true });
return () => viewport.removeEventListener("scroll", update);
}, []);
function scrollToTop() {
const viewport = contentRef.current?.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
viewport?.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
viewport?.focus({ preventScroll: true });
}
return (
<div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex flex-col gap-3 px-4 pt-4 pb-3">
<div className="flex items-baseline justify-between gap-2">
<h3 id="scroll-area-06-title" className="text-sm font-medium">
Changelog
</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{progress}% read
</span>
</div>
<Progress value={progress} aria-label="Reading progress" />
</div>
<div className="group/reader relative border-t">
<ScrollArea
role="region"
aria-labelledby="scroll-area-06-title"
className="h-80 [&_[data-slot=scroll-area-thumb]]:bg-muted-foreground/40 [&_[data-slot=scroll-area-thumb]]:transition-colors hover:[&_[data-slot=scroll-area-thumb]]:bg-muted-foreground/60"
>
<div ref={contentRef} className="flex flex-col gap-6 px-4 py-5">
{releases.map((release) => (
<article key={release.version} className="flex flex-col gap-2">
<header className="flex items-baseline justify-between gap-2">
<h4 className="font-mono text-sm font-medium">
v{release.version}
</h4>
<time className="text-xs text-muted-foreground">
{release.date}
</time>
</header>
<ul className="flex list-disc flex-col gap-1.5 pl-4 text-sm leading-relaxed text-muted-foreground marker:text-border">
{release.notes.map((note) => (
<li key={note} className="text-pretty">
{note}
</li>
))}
</ul>
</article>
))}
</div>
</ScrollArea>
{/* Edge fades driven by the root's data-overflow-y-* attributes. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-10 bg-linear-to-b from-card to-transparent opacity-0 transition-opacity duration-200 group-has-[[data-overflow-y-start]]/reader:opacity-100"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-linear-to-t from-card to-transparent opacity-0 transition-opacity duration-200 group-has-[[data-overflow-y-end]]/reader:opacity-100"
/>
<Button
variant="secondary"
size="icon-sm"
aria-label="Back to top"
onClick={scrollToTop}
tabIndex={progress > 10 ? 0 : -1}
aria-hidden={progress > 10 ? undefined : true}
className="pointer-events-none absolute right-4 bottom-4 translate-y-2 rounded-full opacity-0 shadow-md transition-[opacity,translate] duration-200 ease-out data-[visible=true]:pointer-events-auto data-[visible=true]:translate-y-0 data-[visible=true]:opacity-100 motion-reduce:transition-none"
data-visible={progress > 10}
>
<ArrowUp aria-hidden="true" />
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-06pnpm dlx shadcn@latest add @sevenui/component/scroll-area-06yarn dlx shadcn@latest add @sevenui/component/scroll-area-06bunx --bun shadcn@latest add @sevenui/component/scroll-area-06Effective October 1, 2026. Read through to the end to continue.
Scroll to the end to unlock the agreement.
"use client";
import * as React from "react";
import { ArrowDown, Check, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
const clauses = [
{
heading: "1. Your workspace",
body: "You own the projects, files, and comments you create in Northwind. We store them only to provide the service and never sell or rent them to third parties.",
},
{
heading: "2. Acceptable use",
body: "Do not use the workspace to distribute malware, send unsolicited bulk email, or attempt to access another customer's data. We may suspend accounts that put other customers at risk.",
},
{
heading: "3. Billing and renewals",
body: "Paid plans renew automatically at the end of each billing period. You can cancel at any time from Settings → Billing, and your plan stays active until the period ends.",
},
{
heading: "4. Data retention",
body: "When you delete a project it moves to the trash for 30 days, after which it is permanently erased from our primary systems. Encrypted backups are purged within 90 days.",
},
{
heading: "5. Service availability",
body: "We target 99.9% monthly uptime for the web app and API. Scheduled maintenance is announced at least 72 hours in advance on our status page.",
},
{
heading: "6. Changes to these terms",
body: "If we make material changes, we will notify workspace owners by email 30 days before they take effect. Continuing to use Northwind after that date means you accept the updated terms.",
},
];
export default function ScrollArea07() {
const [reachedEnd, setReachedEnd] = React.useState(false);
const [agreed, setAgreed] = React.useState(false);
const [accepted, setAccepted] = React.useState(false);
const [snoozed, setSnoozed] = React.useState(false);
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
if (reachedEnd) return;
const target = event.target as HTMLElement;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 8) {
setReachedEnd(true);
}
}
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-5 text-card-foreground shadow-sm">
<div className="flex flex-col gap-1">
<h3 id="scroll-area-07-title" className="font-semibold">
Review the updated Terms of Service
</h3>
<p className="text-sm text-muted-foreground">
Effective October 1, 2026. Read through to the end to continue.
</p>
</div>
<ScrollArea
role="region"
aria-labelledby="scroll-area-07-title"
onScrollCapture={handleScroll}
className="h-56 rounded-lg border bg-background after:pointer-events-none after:absolute after:inset-x-0 after:right-2.5 after:bottom-0 after:h-12 after:rounded-bl-lg after:bg-linear-to-t after:from-background after:opacity-0 after:transition-opacity data-overflow-y-end:after:opacity-100"
>
<div className="flex flex-col gap-4 p-4 pr-5 text-sm leading-relaxed">
{clauses.map((clause) => (
<section key={clause.heading} className="flex flex-col gap-1">
<h4 className="font-medium">{clause.heading}</h4>
<p className="text-muted-foreground">{clause.body}</p>
</section>
))}
</div>
</ScrollArea>
<p
aria-live="polite"
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
{snoozed && !accepted ? (
<>
<Clock aria-hidden="true" className="size-3.5" />
We'll remind you again tomorrow. You can still accept now.
</>
) : reachedEnd ? (
<>
<Check aria-hidden="true" className="size-3.5 text-success" />
You have reached the end of the document.
</>
) : (
<>
<ArrowDown aria-hidden="true" className="size-3.5" />
Scroll to the end to unlock the agreement.
</>
)}
</p>
<div className="flex items-start gap-2.5">
<Checkbox
id="scroll-area-07-agree"
checked={agreed}
disabled={!reachedEnd || accepted}
onCheckedChange={(checked) => setAgreed(checked)}
className="mt-0.5"
/>
<Label
htmlFor="scroll-area-07-agree"
className="text-sm leading-snug font-normal"
>
I have read and agree to the Terms of Service on behalf of my
workspace.
</Label>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
disabled={accepted || snoozed}
onClick={() => setSnoozed(true)}
>
{snoozed ? "Reminder set" : "Remind me later"}
</Button>
<Button
disabled={!agreed || accepted}
onClick={() => setAccepted(true)}
>
{accepted ? (
<>
<Check aria-hidden="true" />
Accepted
</>
) : (
"Accept and continue"
)}
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-07pnpm dlx shadcn@latest add @sevenui/component/scroll-area-07yarn dlx shadcn@latest add @sevenui/component/scroll-area-07bunx --bun shadcn@latest add @sevenui/component/scroll-area-07Free shipping on orders over $200. Returns within 30 days.
"use client";
import * as React from "react";
import { Check, Loader2, Lock, Minus, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
type LineItem = {
id: string;
name: string;
variant: string;
price: number;
quantity: number;
};
const initialItems: LineItem[] = [
{
id: "sku-1042",
name: "Merino crew sweater",
variant: "Oat · M",
price: 98,
quantity: 1,
},
{
id: "sku-2210",
name: "Selvedge denim jacket",
variant: "Indigo · L",
price: 185,
quantity: 1,
},
{
id: "sku-3307",
name: "Organic cotton tee",
variant: "Bone · M",
price: 34,
quantity: 3,
},
{
id: "sku-4115",
name: "Canvas weekender bag",
variant: "Olive",
price: 142,
quantity: 1,
},
{
id: "sku-5089",
name: "Wool blend beanie",
variant: "Charcoal",
price: 28,
quantity: 2,
},
{
id: "sku-6021",
name: "Leather card holder",
variant: "Cognac",
price: 45,
quantity: 1,
},
];
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export default function ScrollArea08() {
const [items, setItems] = React.useState(initialItems);
const [checkout, setCheckout] = React.useState<"idle" | "pending" | "done">(
"idle",
);
const checkoutTimer = React.useRef<number | undefined>(undefined);
React.useEffect(() => () => window.clearTimeout(checkoutTimer.current), []);
function startCheckout() {
setCheckout("pending");
window.clearTimeout(checkoutTimer.current);
checkoutTimer.current = window.setTimeout(() => setCheckout("done"), 1200);
}
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const shipping = subtotal >= 200 || subtotal === 0 ? 0 : 12;
const tax = Math.round(subtotal * 0.0825 * 100) / 100;
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
function setQuantity(id: string, delta: number) {
setCheckout("idle");
setItems((current) =>
current.map((i) =>
i.id === id
? { ...i, quantity: Math.min(9, Math.max(1, i.quantity + delta)) }
: i,
),
);
}
function remove(id: string) {
setCheckout("idle");
setItems((current) => current.filter((i) => i.id !== id));
}
return (
<div className="flex w-full max-w-sm flex-col rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex items-baseline justify-between px-5 pt-5 pb-3">
<h3 id="scroll-area-08-title" className="font-semibold">
Order summary
</h3>
<span className="text-sm text-muted-foreground tabular-nums">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</div>
<ScrollArea
role="region"
aria-label="Items in your bag"
className="h-72 border-y bg-muted/20"
>
{items.length === 0 ? (
<p className="px-5 py-12 text-center text-sm text-muted-foreground">
Your bag is empty.
</p>
) : (
<ul className="divide-y">
{items.map((item) => (
<li key={item.id} className="flex gap-3 px-5 py-3.5">
<img
src="/placeholder.svg"
alt=""
className="size-16 shrink-0 rounded-md border bg-muted object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{item.name}
</p>
<p className="text-xs text-muted-foreground">
{item.variant}
</p>
</div>
<p className="text-sm font-medium tabular-nums">
{currency.format(item.price * item.quantity)}
</p>
</div>
<div className="flex items-center justify-between">
<fieldset
aria-label={`Quantity for ${item.name}`}
className="flex items-center rounded-md border bg-background"
>
<Button
variant="ghost"
size="icon-xs"
aria-label="Decrease quantity"
disabled={item.quantity <= 1}
onClick={() => setQuantity(item.id, -1)}
>
<Minus aria-hidden="true" />
</Button>
<span
aria-live="polite"
className="w-6 text-center text-xs tabular-nums"
>
{item.quantity}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label="Increase quantity"
disabled={item.quantity >= 9}
onClick={() => setQuantity(item.id, 1)}
>
<Plus aria-hidden="true" />
</Button>
</fieldset>
<Button
variant="ghost"
size="xs"
className="text-muted-foreground"
onClick={() => remove(item.id)}
>
<Trash2 aria-hidden="true" />
Remove
<span className="sr-only"> {item.name}</span>
</Button>
</div>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
<dl className="flex flex-col gap-1.5 px-5 pt-4 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{currency.format(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">
{shipping === 0 ? "Free" : currency.format(shipping)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Estimated tax</dt>
<dd className="tabular-nums">{currency.format(tax)}</dd>
</div>
<Separator className="my-1.5" />
<div className="flex justify-between font-semibold">
<dt>Total</dt>
<dd className="tabular-nums">
{currency.format(subtotal + shipping + tax)}
</dd>
</div>
</dl>
<div className="flex flex-col gap-2 p-5 pt-4">
<Button
size="lg"
className="w-full"
disabled={items.length === 0 || checkout !== "idle"}
onClick={startCheckout}
>
{checkout === "pending" ? (
<Loader2 aria-hidden="true" className="animate-spin" />
) : checkout === "done" ? (
<Check aria-hidden="true" />
) : (
<Lock aria-hidden="true" />
)}
{checkout === "pending"
? "Securing checkout…"
: checkout === "done"
? "Order placed"
: "Checkout securely"}
</Button>
<p
aria-live="polite"
className="text-center text-xs text-muted-foreground"
>
{checkout === "done"
? "Order NW-48213 confirmed. A receipt is on its way."
: "Free shipping on orders over $200. Returns within 30 days."}
</p>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-08pnpm dlx shadcn@latest add @sevenui/component/scroll-area-08yarn dlx shadcn@latest add @sevenui/component/scroll-area-08bunx --bun shadcn@latest add @sevenui/component/scroll-area-0830 minVideo call
October 2026
Available times · Mon, Oct 5
Times shown in Central European Time (CET).
"use client";
import * as React from "react";
import { Clock, Video } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
const days = [
{ id: "2026-10-05", weekday: "Mon", date: 5, open: 6 },
{ id: "2026-10-06", weekday: "Tue", date: 6, open: 3 },
{ id: "2026-10-07", weekday: "Wed", date: 7, open: 0 },
{ id: "2026-10-08", weekday: "Thu", date: 8, open: 8 },
{ id: "2026-10-09", weekday: "Fri", date: 9, open: 4 },
{ id: "2026-10-12", weekday: "Mon", date: 12, open: 7 },
{ id: "2026-10-13", weekday: "Tue", date: 13, open: 5 },
{ id: "2026-10-14", weekday: "Wed", date: 14, open: 2 },
{ id: "2026-10-15", weekday: "Thu", date: 15, open: 8 },
{ id: "2026-10-16", weekday: "Fri", date: 16, open: 6 },
];
const allSlots = [
"9:00 AM",
"9:30 AM",
"10:00 AM",
"10:30 AM",
"11:00 AM",
"1:00 PM",
"1:30 PM",
"2:00 PM",
"3:00 PM",
"3:30 PM",
"4:00 PM",
"4:30 PM",
];
export default function ScrollArea09() {
const [dayId, setDayId] = React.useState(days[0].id);
const [slot, setSlot] = React.useState<string | null>(null);
const [confirmed, setConfirmed] = React.useState(false);
const day = days.find((d) => d.id === dayId) ?? days[0];
const slots = allSlots.slice(0, day.open);
function pickDay(id: string) {
setDayId(id);
setSlot(null);
setConfirmed(false);
}
return (
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow-sm">
<div className="flex items-start gap-3">
<img
src="/placeholder.svg"
alt=""
className="size-10 shrink-0 rounded-full border bg-muted object-cover"
/>
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Onboarding call with Hannah Weber</h3>
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock aria-hidden="true" className="size-3.5" />
30 min
</span>
<span className="flex items-center gap-1">
<Video aria-hidden="true" className="size-3.5" />
Video call
</span>
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<p id="scroll-area-09-days" className="text-sm font-medium">
October 2026
</p>
<ScrollArea orientation="horizontal" className="-mx-1">
<fieldset
aria-labelledby="scroll-area-09-days"
className="flex w-max gap-2 px-1 pb-3"
>
{days.map((d) => {
const active = d.id === dayId;
const full = d.open === 0;
return (
<button
key={d.id}
type="button"
aria-pressed={active}
aria-label={`${d.weekday} October ${d.date}, ${full ? "fully booked" : `${d.open} times available`}`}
disabled={full}
onClick={() => pickDay(d.id)}
className="flex w-14 flex-col items-center gap-0.5 rounded-lg border bg-background py-2 text-center outline-none transition-colors hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
<span className="text-[0.7rem] font-medium uppercase opacity-80">
{d.weekday}
</span>
<span className="text-lg leading-tight font-semibold tabular-nums">
{d.date}
</span>
<span className="text-[0.65rem] opacity-80">
{full ? "Full" : `${d.open} open`}
</span>
</button>
);
})}
</fieldset>
</ScrollArea>
</div>
<div className="flex flex-col gap-2">
<p id="scroll-area-09-slots" className="text-sm font-medium">
Available times · {day.weekday}, Oct {day.date}
</p>
<ScrollArea className="h-44 rounded-lg border">
<fieldset
aria-labelledby="scroll-area-09-slots"
className="grid grid-cols-2 gap-2 p-2 pr-3.5"
>
{slots.map((time) => (
<button
key={time}
type="button"
aria-pressed={slot === time}
onClick={() => {
setSlot(time);
setConfirmed(false);
}}
className="rounded-md border bg-background px-3 py-2 text-sm font-medium tabular-nums outline-none transition-colors hover:border-primary hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary/10 aria-pressed:text-primary"
>
{time}
</button>
))}
</fieldset>
</ScrollArea>
<p className="text-xs text-muted-foreground">
Times shown in Central European Time (CET).
</p>
</div>
<Button disabled={!slot || confirmed} onClick={() => setConfirmed(true)}>
{confirmed
? `Booked for ${slot}`
: slot
? `Confirm ${day.weekday}, Oct ${day.date} at ${slot}`
: "Select a time"}
</Button>
<p aria-live="polite" className="sr-only">
{confirmed ? `Call booked for ${day.weekday} October ${day.date} at ${slot}.` : ""}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-09pnpm dlx shadcn@latest add @sevenui/component/scroll-area-09yarn dlx shadcn@latest add @sevenui/component/scroll-area-09bunx --bun shadcn@latest add @sevenui/component/scroll-area-09"use client";
import * as React from "react";
import { ArrowDownToLine, Pause, Play, RotateCcw } from "lucide-react";
import { cn } from "cn";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
type Level = "info" | "warn" | "error" | "success";
type LogLine = { time: string; level: Level; text: string };
const script: LogLine[] = [
{ time: "14:02:11", level: "info", text: "Cloning github.com/northwind/storefront (branch: main, commit 8f3c2a1)" },
{ time: "14:02:13", level: "info", text: "Restored build cache from 2026-09-24T18:40:02Z (412 MB)" },
{ time: "14:02:14", level: "info", text: "Detected pnpm@10.4.1 from packageManager field" },
{ time: "14:02:19", level: "info", text: "Lockfile is up to date, resolution step is skipped" },
{ time: "14:02:31", level: "info", text: "Packages: +1,284 · Progress: resolved 1284, reused 1270, downloaded 14, added 1284" },
{ time: "14:02:32", level: "warn", text: "Peer dependency warning: @storybook/react@9.1.0 expects react@^18 but found react@19.2.8" },
{ time: "14:02:33", level: "info", text: "> storefront@2.14.0 build" },
{ time: "14:02:33", level: "info", text: "> next build --turbopack" },
{ time: "14:02:41", level: "info", text: "Creating an optimized production build ..." },
{ time: "14:03:02", level: "info", text: "Compiled successfully in 21.4s" },
{ time: "14:03:03", level: "info", text: "Linting and checking validity of types ..." },
{ time: "14:03:15", level: "warn", text: "app/(shop)/product/[slug]/page.tsx:48:7 — 'relatedProducts' is assigned a value but never used" },
{ time: "14:03:18", level: "info", text: "Collecting page data using 8 workers ..." },
{ time: "14:03:26", level: "info", text: "Generating static pages (0/214) ..." },
{ time: "14:03:39", level: "info", text: "Generating static pages (214/214)" },
{ time: "14:03:41", level: "info", text: "Finalizing page optimization and collecting build traces ..." },
{ time: "14:03:47", level: "info", text: "Route (app) Size First Load JS" },
{ time: "14:03:47", level: "info", text: "┌ ○ / 6.2 kB 148 kB" },
{ time: "14:03:47", level: "info", text: "├ ● /product/[slug] 11.8 kB 164 kB" },
{ time: "14:03:47", level: "info", text: "└ ƒ /api/checkout 0 B 0 B" },
{ time: "14:03:52", level: "info", text: "Uploading build outputs to edge network (1,027 files)" },
{ time: "14:04:05", level: "success", text: "Deployment ready at storefront-8f3c2a1.northwind.app in 1m 54s" },
];
const levelClass: Record<Level, string> = {
info: "text-foreground/80",
warn: "text-warning",
error: "text-destructive",
success: "text-success",
};
export default function ScrollArea10() {
const rootRef = React.useRef<HTMLDivElement>(null);
const [count, setCount] = React.useState(6);
const [running, setRunning] = React.useState(true);
const [follow, setFollow] = React.useState(true);
const done = count >= script.length;
const lines = script.slice(0, count);
const warnings = lines.filter((l) => l.level === "warn").length;
// Stream one line at a time while running; cleaned up on pause/unmount.
React.useEffect(() => {
if (!running || done) return;
const id = window.setInterval(() => {
setCount((c) => Math.min(c + 1, script.length));
}, 700);
return () => window.clearInterval(id);
}, [running, done]);
// Keep the viewport pinned to the newest line while following.
React.useEffect(() => {
if (!follow || count === 0) return;
const viewport = rootRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
if (viewport) viewport.scrollTop = viewport.scrollHeight;
}, [count, follow]);
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
const target = event.target as HTMLElement;
const atBottom =
target.scrollTop + target.clientHeight >= target.scrollHeight - 4;
setFollow(atBottom);
}
function restart() {
setCount(1);
setFollow(true);
setRunning(true);
}
return (
<div className="@container w-full max-w-xl overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-2 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<h3 id="scroll-area-10-title" className="truncate text-sm font-semibold">
Build log · storefront
</h3>
<Badge variant={done ? "secondary" : "outline"}>
{done ? "Ready" : running ? "Building" : "Paused"}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
{done ? (
<Button variant="ghost" size="sm" onClick={restart}>
<RotateCcw aria-hidden="true" />
<span className="@max-sm:sr-only">Replay</span>
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setRunning((r) => !r)}
>
{running ? (
<Pause aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
<span className="@max-sm:sr-only">
{running ? "Pause" : "Resume"}
</span>
</Button>
)}
</div>
</div>
<div className="relative">
<ScrollArea
ref={rootRef}
orientation="both"
role="log"
aria-labelledby="scroll-area-10-title"
onScrollCapture={handleScroll}
className="h-64 bg-muted/40"
>
<ol className="w-max min-w-full py-2 font-mono text-xs leading-6">
{lines.map((line, index) => (
<li
key={`${line.time}-${line.text}`}
className={cn(
"flex gap-4 pr-6 pl-3 whitespace-pre",
line.level === "warn" && "bg-warning/10",
line.level === "success" && "bg-success/10",
)}
>
<span
aria-hidden="true"
className="w-5 shrink-0 text-right text-muted-foreground/70 tabular-nums select-none"
>
{index + 1}
</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{line.time}
</span>
<span className={levelClass[line.level]}>{line.text}</span>
</li>
))}
</ol>
</ScrollArea>
{!follow ? (
<Button
size="sm"
variant="secondary"
onClick={() => setFollow(true)}
className="absolute right-4 bottom-4 shadow-md"
>
<ArrowDownToLine aria-hidden="true" />
Jump to latest
</Button>
) : null}
</div>
<div className="flex items-center justify-between gap-2 border-t px-4 py-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{lines.length} of {script.length} lines
</span>
<span className="tabular-nums">
{warnings} {warnings === 1 ? "warning" : "warnings"} · 0 errors
</span>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-10pnpm dlx shadcn@latest add @sevenui/component/scroll-area-10yarn dlx shadcn@latest add @sevenui/component/scroll-area-10bunx --bun shadcn@latest add @sevenui/component/scroll-area-10Parcelhub support · Online
"use client";
import * as React from "react";
import { SendHorizontal } from "lucide-react";
import { cn } from "cn";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
type Message = {
id: number;
from: "agent" | "customer";
text: string;
time: string;
};
const initialMessages: Message[] = [
{
id: 1,
from: "agent",
text: "Hi Alex, I'm Rosa from Parcelhub support. How can I help today?",
time: "10:02",
},
{
id: 2,
from: "customer",
text: "My order #PH-20931 says delivered, but nothing arrived.",
time: "10:03",
},
{
id: 3,
from: "agent",
text: "Sorry about that. I can see the courier marked it delivered at 4:12 PM yesterday with a photo at the side entrance.",
time: "10:04",
},
{
id: 4,
from: "agent",
text: "Could you check the side entrance or with a neighbor? If it isn't there, I can open a trace right away.",
time: "10:04",
},
{
id: 5,
from: "customer",
text: "I checked both, it's not there.",
time: "10:06",
},
{
id: 6,
from: "agent",
text: "Thanks for checking. I've opened trace #TR-5512 with the courier. You'll hear back within 24 hours.",
time: "10:07",
},
];
const quickReplies = [
"Send a replacement instead",
"Refund to my card",
"Change delivery address",
"Talk to a supervisor",
"That's all, thanks",
];
export default function ScrollArea11() {
const rootRef = React.useRef<HTMLDivElement>(null);
const [messages, setMessages] = React.useState(initialMessages);
const [draft, setDraft] = React.useState("");
// Scroll to the newest message whenever the thread grows.
React.useEffect(() => {
if (messages.length === 0) return;
const viewport = rootRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
if (viewport) viewport.scrollTop = viewport.scrollHeight;
}, [messages.length]);
function send(text: string) {
const trimmed = text.trim();
if (!trimmed) return;
setMessages((current) => [
...current,
{ id: current.length + 1, from: "customer", text: trimmed, time: "10:08" },
]);
setDraft("");
}
return (
<div className="flex w-full max-w-sm flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Avatar>
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>RM</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<h3 id="scroll-area-11-title" className="text-sm font-semibold">
Rosa Martín
</h3>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span aria-hidden="true" className="size-1.5 rounded-full bg-success" />
Parcelhub support · Online
</p>
</div>
</div>
<ScrollArea
ref={rootRef}
role="log"
aria-labelledby="scroll-area-11-title"
className="h-80"
>
<div className="flex flex-col gap-2 px-4 py-4">
<p className="pb-2 text-center text-xs text-muted-foreground">
Today · Conversation started 10:02
</p>
{messages.map((message, index) => {
const mine = message.from === "customer";
const next = messages[index + 1];
const lastInGroup = !next || next.from !== message.from;
return (
<div
key={message.id}
className={cn(
"flex max-w-[82%] flex-col gap-1",
mine ? "items-end self-end" : "items-start self-start",
)}
>
<p
className={cn(
"rounded-2xl px-3.5 py-2 text-sm leading-snug",
mine
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
lastInGroup && (mine ? "rounded-br-md" : "rounded-bl-md"),
)}
>
<span className="sr-only">
{mine ? "You" : "Rosa"}:{" "}
</span>
{message.text}
</p>
{lastInGroup ? (
<span className="px-1 text-[0.7rem] text-muted-foreground tabular-nums">
{message.time}
</span>
) : null}
</div>
);
})}
</div>
</ScrollArea>
<div className="flex flex-col gap-2 border-t pt-3 pb-3">
<ScrollArea orientation="horizontal">
<ul aria-label="Suggested replies" className="flex w-max gap-2 px-4 pb-2.5">
{quickReplies.map((reply) => (
<li key={reply}>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => send(reply)}
>
{reply}
</Button>
</li>
))}
</ul>
</ScrollArea>
<form
className="flex gap-2 px-4"
onSubmit={(event) => {
event.preventDefault();
send(draft);
}}
>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Write a message"
aria-label="Message"
/>
<Button
type="submit"
size="icon"
aria-label="Send message"
disabled={!draft.trim()}
>
<SendHorizontal aria-hidden="true" />
</Button>
</form>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-11pnpm dlx shadcn@latest add @sevenui/component/scroll-area-11yarn dlx shadcn@latest add @sevenui/component/scroll-area-11bunx --bun shadcn@latest add @sevenui/component/scroll-area-11"use client";
import * as React from "react";
import { Bell, CreditCard, ShieldCheck, TriangleAlert, User } from "lucide-react";
import { cn } from "cn";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
const sections = [
{ id: "profile", label: "Profile", icon: User },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: ShieldCheck },
{ id: "billing", label: "Billing", icon: CreditCard },
{ id: "danger", label: "Danger zone", icon: TriangleAlert },
] as const;
type SectionId = (typeof sections)[number]["id"];
const emailSettings = [
{
id: "weekly",
label: "Weekly digest",
description: "A Monday summary of activity across your projects.",
defaultChecked: true,
},
{
id: "mentions",
label: "Mentions and replies",
description: "When someone mentions you or replies to your comment.",
defaultChecked: true,
},
{
id: "product",
label: "Product updates",
description: "New features and improvements, about once a month.",
defaultChecked: false,
},
];
const sessions = [
{ device: "MacBook Pro · Safari", place: "Lisbon, PT", current: true },
{ device: "iPhone 16 · Northwind app", place: "Lisbon, PT", current: false },
{ device: "Windows · Chrome", place: "Porto, PT", current: false },
];
export default function ScrollArea12() {
const rootRef = React.useRef<HTMLDivElement>(null);
const sectionRefs = React.useRef<Partial<Record<SectionId, HTMLElement>>>(
{},
);
// True while a nav jump is scrolling; the spy stays quiet until it settles.
const jumpingRef = React.useRef(false);
const settleTimer = React.useRef<number | undefined>(undefined);
const [active, setActive] = React.useState<SectionId>("profile");
const [signedOut, setSignedOut] = React.useState<string[]>([]);
const [cardLinkSent, setCardLinkSent] = React.useState(false);
const [deleteStep, setDeleteStep] = React.useState<
"idle" | "confirm" | "scheduled"
>("idle");
function getViewport() {
return rootRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
}
function registerSection(id: SectionId) {
return (el: HTMLElement | null) => {
if (el) sectionRefs.current[id] = el;
else delete sectionRefs.current[id];
};
}
// Scroll-spy: the last section whose top has passed the viewport's top edge wins.
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
const viewport = event.target as HTMLElement;
if (viewport !== getViewport()) return;
if (jumpingRef.current) {
// Release once scroll events stop arriving.
window.clearTimeout(settleTimer.current);
settleTimer.current = window.setTimeout(() => {
jumpingRef.current = false;
}, 150);
return;
}
const top = viewport.getBoundingClientRect().top;
const atEnd =
viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 4;
let current: SectionId = sections[0].id;
for (const section of sections) {
const el = sectionRefs.current[section.id];
if (el && el.getBoundingClientRect().top - top <= 48) {
current = section.id;
}
}
setActive(atEnd ? sections[sections.length - 1].id : current);
}
// Any manual scroll input hands control back to the spy.
function releaseJump() {
window.clearTimeout(settleTimer.current);
jumpingRef.current = false;
}
React.useEffect(() => () => window.clearTimeout(settleTimer.current), []);
function jumpTo(id: SectionId) {
const viewport = getViewport();
const el = sectionRefs.current[id];
setActive(id);
if (!viewport || !el) return;
const offset =
el.getBoundingClientRect().top -
viewport.getBoundingClientRect().top +
viewport.scrollTop;
// Sections near the bottom cannot reach the top edge; aim for what is reachable.
const target = Math.max(
0,
Math.min(offset - 8, viewport.scrollHeight - viewport.clientHeight),
);
window.clearTimeout(settleTimer.current);
jumpingRef.current = Math.abs(viewport.scrollTop - target) > 1;
const reduce = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
viewport.scrollTo?.({ top: target, behavior: reduce ? "auto" : "smooth" });
}
return (
<div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm sm:flex-row">
<div className="border-b bg-muted/30 sm:w-44 sm:shrink-0 sm:border-r sm:border-b-0">
<h3 className="hidden px-4 pt-4 pb-2 text-sm font-semibold sm:block">
Settings
</h3>
<ScrollArea orientation="horizontal">
<nav aria-label="Settings sections">
<ul className="flex w-max gap-1 p-2 sm:w-full sm:flex-col">
{sections.map((section) => {
const Icon = section.icon;
const isActive = active === section.id;
return (
<li key={section.id}>
<button
type="button"
aria-current={isActive ? "location" : undefined}
onClick={() => jumpTo(section.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm whitespace-nowrap text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
isActive && "bg-background font-medium text-foreground shadow-xs",
)}
>
<Icon aria-hidden="true" className="size-4" />
{section.label}
</button>
</li>
);
})}
</ul>
</nav>
</ScrollArea>
</div>
<ScrollArea
ref={rootRef}
onScrollCapture={handleScroll}
onWheelCapture={releaseJump}
onTouchStartCapture={releaseJump}
onPointerDownCapture={releaseJump}
onKeyDownCapture={releaseJump}
className="h-[26rem] min-w-0 sm:flex-1"
>
<div className="flex flex-col gap-8 p-5 pr-6">
<section
ref={registerSection("profile")}
aria-labelledby="scroll-area-12-profile-title"
className="flex flex-col gap-4"
>
<div>
<h4 id="scroll-area-12-profile-title" className="font-semibold">
Profile
</h4>
<p className="text-sm text-muted-foreground">
How teammates see you across Northwind.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="scroll-area-12-name">Full name</Label>
<Input id="scroll-area-12-name" defaultValue="Alex Morgan" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="scroll-area-12-title">Job title</Label>
<Input id="scroll-area-12-title" defaultValue="Product designer" />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="scroll-area-12-email">Email</Label>
<Input
id="scroll-area-12-email"
type="email"
defaultValue="alex@northwind.io"
/>
</div>
</div>
</section>
<section
ref={registerSection("notifications")}
aria-labelledby="scroll-area-12-notifications-title"
className="flex flex-col gap-4"
>
<div>
<h4
id="scroll-area-12-notifications-title"
className="font-semibold"
>
Notifications
</h4>
<p className="text-sm text-muted-foreground">
Choose which emails land in your inbox.
</p>
</div>
{emailSettings.map((setting) => (
<div
key={setting.id}
className="flex items-start justify-between gap-4"
>
<div className="flex flex-col gap-0.5">
<Label htmlFor={`scroll-area-12-${setting.id}`}>
{setting.label}
</Label>
<span className="text-xs text-muted-foreground">
{setting.description}
</span>
</div>
<Switch
id={`scroll-area-12-${setting.id}`}
defaultChecked={setting.defaultChecked}
/>
</div>
))}
</section>
<section
ref={registerSection("security")}
aria-labelledby="scroll-area-12-security-title"
className="flex flex-col gap-4"
>
<div>
<h4 id="scroll-area-12-security-title" className="font-semibold">
Security
</h4>
<p className="text-sm text-muted-foreground">
Two-factor authentication is on. Review where you're signed in.
</p>
</div>
<ul className="divide-y rounded-lg border">
{sessions
.filter((session) => !signedOut.includes(session.device))
.map((session) => (
<li
key={session.device}
className="flex items-center justify-between gap-3 px-3 py-2.5"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{session.device}
</p>
<p className="text-xs text-muted-foreground">
{session.place}
</p>
</div>
{session.current ? (
<Badge variant="secondary">This device</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() =>
setSignedOut((current) => [...current, session.device])
}
>
Sign out
<span className="sr-only"> {session.device}</span>
</Button>
)}
</li>
))}
</ul>
{signedOut.length > 0 ? (
<p aria-live="polite" className="text-xs text-muted-foreground">
Signed out of {signedOut.length}{" "}
{signedOut.length === 1 ? "device" : "devices"}.
</p>
) : null}
</section>
<section
ref={registerSection("billing")}
aria-labelledby="scroll-area-12-billing-title"
className="flex flex-col gap-4"
>
<div>
<h4 id="scroll-area-12-billing-title" className="font-semibold">
Billing
</h4>
<p className="text-sm text-muted-foreground">
Team plan · 12 seats · renews November 1, 2026.
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-3">
<div className="flex items-center gap-3">
<CreditCard
aria-hidden="true"
className="size-5 text-muted-foreground"
/>
<div>
<p className="text-sm font-medium">Visa ending in 4242</p>
<p className="text-xs text-muted-foreground">Expires 08/28</p>
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={cardLinkSent}
onClick={() => setCardLinkSent(true)}
>
{cardLinkSent ? "Link sent" : "Update card"}
</Button>
</div>
<p aria-live="polite" className="text-xs text-muted-foreground">
{cardLinkSent
? "We emailed alex@northwind.io a secure link to update your card."
: ""}
</p>
</section>
<section
ref={registerSection("danger")}
aria-labelledby="scroll-area-12-danger-title"
className="flex flex-col gap-3 rounded-lg border border-destructive/40 p-4"
>
<div>
<h4
id="scroll-area-12-danger-title"
className="font-semibold text-destructive"
>
Delete account
</h4>
<p className="text-sm text-muted-foreground">
Permanently remove your account and personal data. Projects you
own will be transferred to a workspace admin.
</p>
</div>
{deleteStep === "idle" ? (
<Button
variant="destructive"
className="self-start"
onClick={() => setDeleteStep("confirm")}
>
Delete my account
</Button>
) : null}
{deleteStep === "confirm" ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="destructive"
onClick={() => setDeleteStep("scheduled")}
>
Yes, delete my account
</Button>
<Button variant="outline" onClick={() => setDeleteStep("idle")}>
Cancel
</Button>
</div>
) : null}
{deleteStep === "scheduled" ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<p role="status" className="text-sm font-medium">
Deletion scheduled for October 9, 2026.
</p>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteStep("idle")}
>
Undo
</Button>
</div>
) : null}
</section>
</div>
</ScrollArea>
</div>
);
}
npx shadcn@latest add @sevenui/component/scroll-area-12pnpm dlx shadcn@latest add @sevenui/component/scroll-area-12yarn dlx shadcn@latest add @sevenui/component/scroll-area-12bunx --bun shadcn@latest add @sevenui/component/scroll-area-12