Layout
Scroll area
A scrolling region whose edges darken on the sides with more content. Thin scrollbars, keyboard focusable.
- Built on
Scroll-driven animations@propertyscrollbar-colorscrollbar-width- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.4 kB
- Dependencies
- React
- 2.4.0Dark mode follows the system setting
- 2.3.2Fixed CSV export dropping the last row
- 2.3.1Faster search on large workspaces
- 2.3.0Invite teammates by link
- 2.2.4Webhook retries back off exponentially
- 2.2.0Audit log export
Try its options
Loading its options.
Code
import { ScrollArea } from "@/components/equip/scroll-area";
const changes = [
["2.4.0", "Dark mode follows the system setting"],
["2.3.2", "Fixed CSV export dropping the last row"],
["2.3.1", "Faster search on large workspaces"],
["2.3.0", "Invite teammates by link"],
["2.2.4", "Webhook retries back off exponentially"],
["2.2.0", "Audit log export"],
];
export default function Demo() {
return (
<ScrollArea label="Changelog" maxHeight={110} style={{ width: 216 }}>
<ul style={{ margin: 0, padding: "6px 10px", listStyle: "none", fontSize: 12, lineHeight: 1.35 }}>
{changes.map(([version, text]) => (
<li key={version} style={{ display: "flex", gap: 8, padding: "3px 0" }}>
<span
style={{
flex: "none",
width: 34,
color: "var(--eq-ink-2)",
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
}}
>
{version}
</span>
<span>{text}</span>
</li>
))}
</ul>
</ScrollArea>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div role="region" tabindex="0" aria-label="Changelog" class="eq-scroll-area" data-orientation="vertical" style="max-height:110px;width:216px">
<ul style="margin:0;padding:6px 10px;list-style:none;font-size:12px;line-height:1.35">
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.4.0</span>
<span>Dark mode follows the system setting</span>
</li>
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.3.2</span>
<span>Fixed CSV export dropping the last row</span>
</li>
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.3.1</span>
<span>Faster search on large workspaces</span>
</li>
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.3.0</span>
<span>Invite teammates by link</span>
</li>
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.2.4</span>
<span>Webhook retries back off exponentially</span>
</li>
<li style="display:flex;gap:8px;padding:3px 0">
<span style="flex:none;width:34px;color:var(--eq-ink-2);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">2.2.0</span>
<span>Audit log export</span>
</li>
</ul>
</div>Examples
Horizontal
The left edge darkens only once you have scrolled, and the right edge clears at the end.
us-east-1us-west-2eu-west-1eu-central-1ap-south-1ap-northeast-1sa-east-1
Code
import { ScrollArea } from "@/components/equip/scroll-area";
const regions = ["us-east-1", "us-west-2", "eu-west-1", "eu-central-1", "ap-south-1", "ap-northeast-1", "sa-east-1"];
export default function Example() {
return (
<ScrollArea label="Regions" orientation="horizontal" style={{ width: "100%", maxWidth: 320 }}>
<div style={{ display: "flex", gap: 8, padding: 12, width: "max-content" }}>
{regions.map((region) => (
<span
key={region}
style={{
padding: "4px 8px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-s)",
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: "var(--eq-text-xs)",
}}
>
{region}
</span>
))}
</div>
</ScrollArea>
);
}HTMLworks in any framework, with the part's CSS
<div role="region" tabindex="0" aria-label="Regions" class="eq-scroll-area" data-orientation="horizontal" style="width:100%;max-width:320px">
<div style="display:flex;gap:8px;padding:12px;width:max-content">
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">us-east-1</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">us-west-2</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">eu-west-1</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">eu-central-1</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">ap-south-1</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">ap-northeast-1</span>
<span style="padding:4px 8px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-s);font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs)">sa-east-1</span>
</div>
</div>Both directions
Tab to the area and use the arrow keys: a scrollable region has to take focus for keyboard users.
12:04:11 GET /api/projects 200 18ms 12:04:12 POST /api/projects/482/deploy 202 141ms 12:04:12 deploy 482 queued on worker eu-west-1-b 12:04:19 build 482 cache hit for node_modules (412 MB) 12:04:33 build 482 finished in 21.4s 12:04:34 deploy 482 promoted to production 12:04:35 GET /api/projects/482/status 200 9ms 12:05:02 webhook delivered to https://hooks.example.com/deploys
Code
import { ScrollArea } from "@/components/equip/scroll-area";
const log = [
"12:04:11 GET /api/projects 200 18ms",
"12:04:12 POST /api/projects/482/deploy 202 141ms",
"12:04:12 deploy 482 queued on worker eu-west-1-b",
"12:04:19 build 482 cache hit for node_modules (412 MB)",
"12:04:33 build 482 finished in 21.4s",
"12:04:34 deploy 482 promoted to production",
"12:04:35 GET /api/projects/482/status 200 9ms",
"12:05:02 webhook delivered to https://hooks.example.com/deploys",
];
export default function Example() {
return (
<ScrollArea label="Deploy log" orientation="both" maxHeight={140} style={{ width: "100%", maxWidth: 320 }}>
<pre
style={{
margin: 0,
padding: 12,
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: "var(--eq-text-xs)",
lineHeight: 1.6,
}}
>
{log.join("\n")}
</pre>
</ScrollArea>
);
}HTMLworks in any framework, with the part's CSS
<div role="region" tabindex="0" aria-label="Deploy log" class="eq-scroll-area" data-orientation="both" style="max-height:140px;width:100%;max-width:320px">
<pre style="margin:0;padding:12px;font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings);font-size:var(--eq-text-xs);line-height:1.6">12:04:11 GET /api/projects 200 18ms
12:04:12 POST /api/projects/482/deploy 202 141ms
12:04:12 deploy 482 queued on worker eu-west-1-b
12:04:19 build 482 cache hit for node_modules (412 MB)
12:04:33 build 482 finished in 21.4s
12:04:34 deploy 482 promoted to production
12:04:35 GET /api/projects/482/status 200 9ms
12:05:02 webhook delivered to https://hooks.example.com/deploys</pre>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the region for screen readers. Required, because the region takes focus. |
orientation | "vertical" | "horizontal" | "both" | "vertical" | Which way the content scrolls. The other axis is clipped. |
maxHeight | CSSProperties["maxHeight"] | none | Height at which it stops growing and starts scrolling, such as 110 or "40vh". |
children | ReactNode | none | The content that scrolls. |
...rest | ComponentProps<"div"> | none | Every native div attribute. Width limits for horizontal scrolling go in style. |
Anatomy
| root | .eq-scroll-area | The scroller itself: a div with role="region", tabindex="0" and inset edge shadows. |
|---|
Keyboard
| Tab | Focuses the region, so it can be scrolled without a pointer. |
|---|---|
| ArrowUpArrowDownArrowLeftArrowRight | Scrolls the focused region a step. |
| PageUpPageDownHomeEnd | Scrolls the focused region a page, or to either end. |
Notes
- A scrollable region must be reachable by keyboard, so it is always a tab stop with role="region" and the name you give it.
- Renders on the server with no client JavaScript; the scroll position drives the shadows through CSS alone.
- An edge darkens only when there is more content past it, and a region with nothing to scroll shows no edges.
- The shadows are the ink colour mixed with transparent, so they work on any surface and any finish.
- The shadows are painted on the scroller, under its children: content with an opaque background covers them.
- Horizontal edge shadows are physical left and right, so in a right-to-left layout they sit on the opposite sides.
- Arrow keys scroll the region natively; a focusable child inside it takes those keys while it has focus.
- The edge shadows need scroll-driven animations (animation-timeline: scroll()), which not every engine has; elsewhere the region scrolls normally with no shadows. Thin, token coloured scrollbars need scrollbar-color and scrollbar-width; engines without them show the platform scrollbar.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/scroll-area.tsx35 lines
import type { ComponentProps, CSSProperties } from "react";
import "./scroll-area.css";
export type ScrollAreaProps = ComponentProps<"div"> & {
/** Names the region. A scrollable region takes focus, so it needs a name. */
label: string;
/** Which way the content scrolls. */
orientation?: "vertical" | "horizontal" | "both";
/** Height at which the area stops growing and starts scrolling, e.g. 110 or "40vh". */
maxHeight?: CSSProperties["maxHeight"];
};
export function ScrollArea({
label,
orientation = "vertical",
maxHeight,
className,
style,
children,
...rest
}: ScrollAreaProps) {
return (
<div
role="region"
tabIndex={0}
aria-label={label}
{...rest}
className={["eq-scroll-area", className].filter(Boolean).join(" ")}
data-orientation={orientation}
style={{ maxHeight, ...style }}
>
{children}
</div>
);
}components/equip/scroll-area.css120 lines
@layer equip {
/* Registered so the scroll timeline can interpolate them. Names are part-specific
because registrations are global. */
@property --_scroll-area-top {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --_scroll-area-bottom {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --_scroll-area-start {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --_scroll-area-end {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.eq-scroll-area {
--_depth: 12px;
box-sizing: border-box;
overflow: hidden auto;
overscroll-behavior: contain;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
color: var(--eq-ink);
scrollbar-width: thin;
scrollbar-color: var(--eq-line-strong) transparent;
/* Ink mixed with transparent, so the edge reads on any surface and any finish.
Each edge is 0% until the scroll timeline says there is more content that way. */
box-shadow:
inset 0 var(--_depth) var(--_depth) calc(var(--_depth) / -2)
color-mix(in srgb, var(--eq-ink) var(--_scroll-area-top), transparent),
inset 0 calc(var(--_depth) * -1) var(--_depth) calc(var(--_depth) / -2)
color-mix(in srgb, var(--eq-ink) var(--_scroll-area-bottom), transparent),
inset var(--_depth) 0 var(--_depth) calc(var(--_depth) / -2)
color-mix(in srgb, var(--eq-ink) var(--_scroll-area-start), transparent),
inset calc(var(--_depth) * -1) 0 var(--_depth) calc(var(--_depth) / -2)
color-mix(in srgb, var(--eq-ink) var(--_scroll-area-end), transparent);
}
.eq-scroll-area[data-orientation="horizontal"] {
overflow: auto hidden;
}
.eq-scroll-area[data-orientation="both"] {
overflow: auto;
}
.eq-scroll-area:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Without scroll-driven animations the edges simply stay clear. The guard matters:
an engine that ignores animation-timeline would otherwise run these on the clock. */
@supports (animation-timeline: scroll()) {
.eq-scroll-area {
animation: eq-scroll-area-block linear both;
animation-timeline: scroll(self block);
}
.eq-scroll-area[data-orientation="horizontal"] {
animation: eq-scroll-area-inline linear both;
animation-timeline: scroll(self inline);
}
.eq-scroll-area[data-orientation="both"] {
animation:
eq-scroll-area-block linear both,
eq-scroll-area-inline linear both;
animation-timeline: scroll(self block), scroll(self inline);
}
}
/* A timeline with nothing to scroll is inactive, so a short list shows no edges. */
@keyframes eq-scroll-area-block {
0% {
--_scroll-area-top: 0%;
--_scroll-area-bottom: 18%;
}
6% {
--_scroll-area-top: 18%;
}
94% {
--_scroll-area-bottom: 18%;
}
100% {
--_scroll-area-top: 18%;
--_scroll-area-bottom: 0%;
}
}
@keyframes eq-scroll-area-inline {
0% {
--_scroll-area-start: 0%;
--_scroll-area-end: 18%;
}
6% {
--_scroll-area-start: 18%;
}
94% {
--_scroll-area-end: 18%;
}
100% {
--_scroll-area-start: 18%;
--_scroll-area-end: 0%;
}
}
}