Scroll area
PortedA box that scrolls its content, with thin scrollbars that show while the pointer is over it or while it scrolls. The app sets its size with className: a height, or a max height that it grows to. Scrolling stays native: wheel, touch, keys and find in page. When the content overflows, the region takes focus, so the arrow keys scroll it.
import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";
/** A panel edge around the box, like Orbit's cards. The app sets the size. */
const box = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" });
const activity = [
"Ana Ruiz moved to Interviewing",
"Kai Ito left feedback on José Núñez",
"Sam Lee scheduled an onsite for Mira Osei",
"Lee Park declined the offer",
"New referral: Priya Raman, from Kai Ito",
"José Núñez moved to References check",
"Mira Osei accepted the offer",
"Sam Lee archived 4 candidates",
"Ana Ruiz uploaded a new resume",
"Kai Ito added a note to Priya Raman",
"Weekly digest sent to 12 people",
"Lee Park moved to We Passed",
];
function Activity({ count }: { count: number }) {
return (
<div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
{activity.slice(0, count).map((line) => (
<Text key={line} as="p" size="sm">
{line}
</Text>
))}
</div>
);
}
export function Preview() {
return (
<ScrollArea aria-label="Activity" style={box({ blockSize: "8rem", inlineSize: "16rem" })}>
<Activity count={12} />
</ScrollArea>
);
}Installation
import { ScrollArea } from "@etched/monet/scroll-area";Examples
A fixed height
The thin scrollbar shows while the pointer is over the box or while it scrolls. The region takes focus, so the arrow keys scroll it.
import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";
/** A panel edge around the box, like Orbit's cards. The app sets the size. */
const box = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" });
const activity = [
"Ana Ruiz moved to Interviewing",
"Kai Ito left feedback on José Núñez",
"Sam Lee scheduled an onsite for Mira Osei",
"Lee Park declined the offer",
"New referral: Priya Raman, from Kai Ito",
"José Núñez moved to References check",
"Mira Osei accepted the offer",
"Sam Lee archived 4 candidates",
"Ana Ruiz uploaded a new resume",
"Kai Ito added a note to Priya Raman",
"Weekly digest sent to 12 people",
"Lee Park moved to We Passed",
];
function Activity({ count }: { count: number }) {
return (
<div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
{activity.slice(0, count).map((line) => (
<Text key={line} as="p" size="sm">
{line}
</Text>
))}
</div>
);
}
export function FixedHeight() {
return (
<ScrollArea aria-label="Recent activity" style={box({ blockSize: "12rem", maxInlineSize: "24rem" })}>
<Activity count={12} />
</ScrollArea>
);
}Up to a max height
With a max height, the box grows with its content and scrolls only past it. A box that does not scroll takes no focus and shows no scrollbar.
import type { CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { ScrollArea } from "@etched/monet/scroll-area";
/** A panel edge around the box, like Orbit's cards. The app sets the size. */
const box = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" });
const activity = [
"Ana Ruiz moved to Interviewing",
"Kai Ito left feedback on José Núñez",
"Sam Lee scheduled an onsite for Mira Osei",
"Lee Park declined the offer",
"New referral: Priya Raman, from Kai Ito",
"José Núñez moved to References check",
"Mira Osei accepted the offer",
"Sam Lee archived 4 candidates",
"Ana Ruiz uploaded a new resume",
"Kai Ito added a note to Priya Raman",
"Weekly digest sent to 12 people",
"Lee Park moved to We Passed",
];
function Activity({ count }: { count: number }) {
return (
<div style={{ display: "grid", gap: "var(--et-space-2)", padding: "var(--et-space-3) var(--et-space-4)" }}>
{activity.slice(0, count).map((line) => (
<Text key={line} as="p" size="sm">
{line}
</Text>
))}
</div>
);
}
export function MaxHeight() {
return (
<div className="row" style={{ alignItems: "flex-start" }}>
<ScrollArea aria-label="Short activity" style={box({ maxBlockSize: "10rem", inlineSize: "16rem" })}>
<Activity count={2} />
</ScrollArea>
<ScrollArea aria-label="Long activity" style={box({ maxBlockSize: "10rem", inlineSize: "16rem" })}>
<Activity count={12} />
</ScrollArea>
</div>
);
}Wide content
Content wider than the box scrolls sideways, with a horizontal scrollbar.
import type { CSSProperties } from "react";
import { Pill, type PillTone } from "@etched/monet/pill";
import { ScrollArea } from "@etched/monet/scroll-area";
/** A panel edge around the box, like Orbit's cards. The app sets the size. */
const box = (size: CSSProperties): CSSProperties => ({ ...size, borderRadius: "var(--et-card-radius)", boxShadow: "0 0 0 1px var(--et-line)" });
const stages: [string, PillTone][] = [
["To Do", "neutral"],
["Activating", "blue"],
["Scheduling", "orange"],
["Scheduled", "purple"],
["In Touch", "teal"],
["Interviewing", "cyan"],
["References check", "cyan"],
["No Response", "amber"],
["Revisit", "olive"],
["Hired", "green"],
["We Passed", "red"],
];
export function WideContent() {
return (
<ScrollArea aria-label="Stages" style={box({ maxInlineSize: "24rem" })}>
<div className="row" style={{ flexWrap: "nowrap", padding: "var(--et-space-3)" }}>
{stages.map(([stage, tone]) => (
<Pill key={stage} tone={tone}>
{stage}
</Pill>
))}
</div>
</ScrollArea>
);
}Accessibility
- When the content overflows, the box is in the
Taborder. Then the arrow keys andHome/Endscroll it, as the browser does. - The scrolling box gets
aria-labeloraria-labelledby, and a named box is aregion. The scrollbars arearia-hidden, and a box that fits its content takes no focus. - Name it with
aria-label, or witharia-labelledbyon a heading, so the box has a name when it takes focus.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | |
| viewportRef | Ref<HTMLDivElement> | – | The element that scrolls, for scrollTo or a table of contents' root. ref is the outer box. |
| aria-label | string | – | Names the box that scrolls, which is then a region; it takes focus when its content overflows. Set it (or aria-labelledby) when no heading names the box. |