Overflow fade
Fades the clipped edge of a horizontal row over 2rem, only on the side that is clipped.
import { useOverflowFade } from "@oration/canon/hooks/use-overflow-fade";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() { const views = [ { name: "All invoices", count: 1284 }, { name: "Awaiting approval", count: 37 }, { name: "Scheduled", count: 212 }, { name: "Paid", count: 961 }, { name: "Exceptions", count: 12 }, { name: "On hold", count: 8 }, { name: "Disputed", count: 3 }, { name: "Archived", count: 4410 }, ]; const [view, setView] = React.useState("All invoices"); const tabs = React.useRef<(HTMLButtonElement | null)[]>([]); const fadeRef = useOverflowFade<HTMLDivElement>(); const onKeyDown = (event: React.KeyboardEvent, index: number) => { if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; event.preventDefault(); const step = event.key === "ArrowRight" ? 1 : -1; const next = (index + step + views.length) % views.length; const target = views[next]; if (!target) return; setView(target.name); tabs.current[next]?.focus(); }; return ( <div className="w-full max-w-sm rounded-xl bg-card p-2 shadow-border"> <div ref={fadeRef} data-overflow-fade role="tablist" aria-label="Invoice views" className="flex min-w-0 items-center gap-0.5 overflow-x-auto no-scrollbar" > {views.map((item, index) => { const active = item.name === view; return ( <button key={item.name} ref={(node) => { tabs.current[index] = node; }} type="button" role="tab" aria-selected={active} tabIndex={active ? 0 : -1} onClick={() => setView(item.name)} onKeyDown={(event) => onKeyDown(event, index)} className={cn( "flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40 focus-visible:ring-inset", active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {item.name} <span className="text-xs font-normal text-muted-foreground tabular-nums"> {item.count.toLocaleString("en-US")} </span> </button> ); })} </div> </div> );}Usage#
useOverflowFade is the hook behind The Scroll Edge Rule for horizontal rows. It returns a callback ref that marks a scroller with data-overflow-start and data-overflow-end while content is clipped on that side, and the [data-overflow-fade] styles in globals.css fade that edge over 2rem. It also scrolls the selected item fully into view. Scrollable tabs, code snippet tabs and record view tabs use it. The usual mistake is attaching the ref without the data-overflow-fade attribute, which tracks the overflow but draws no fade.
When to use
- Horizontal tab and view rows that can outgrow their container: record tabs, view tabs, settings sub-tabs.
- Scrolling strips of chips or filters in a toolbar.
- Language tabs above a code sample.
- Any row where the selected item must stay in view as the selection changes.
When not to use
- Tabs from the Tabs component.
TabsList scrollablealready applies the hook and the attribute. Use Tabs - Vertical scrolling regions. The fade styles are horizontal only. Use Scroll area
- A data grid's pinned columns, which show their edge with a shadow once the grid scrolls sideways. Use Data grid
- Slides of content that page one at a time. Use Carousel
- A row of three or four items with room to wrap. Let it wrap instead of scrolling.
The Scroll Edge Rule
Anatomy#
- Scroller. The element that takes the ref:
overflow-x-auto no-scrollbarplus thedata-overflow-fadeattribute. - Start fade. Drawn while
data-overflow-startis set, once the row has scrolled more than 1px. - End fade. Drawn while
data-overflow-endis set, until the row reaches its last pixel. Both set means a fade on each side. - Selected item. Matched by
[aria-selected="true"]by default. Scrolled to sit 24px inside the edge.
Examples#
Start, end or both
Only the clipped side fades. Each row selects a different view, and the hook scrolls it into view on attach. These rows pass a custom selector, [data-selected].
import { useOverflowFade } from "@oration/canon/hooks/use-overflow-fade";import { cn } from "@oration/canon/lib/utils";export function ClippedEdges() { const views = [ "Overview", "Invoices", "Payments", "Remittances", "Documents", "Tax forms", "Activity", "Notes", ]; const startRef = useOverflowFade<HTMLDivElement>("[data-selected]"); const middleRef = useOverflowFade<HTMLDivElement>("[data-selected]"); const endRef = useOverflowFade<HTMLDivElement>("[data-selected]"); const rows = [ { label: "Clipped at the end", ref: startRef, selected: "Overview" }, { label: "Clipped on both sides", ref: middleRef, selected: "Documents", }, { label: "Clipped at the start", ref: endRef, selected: "Notes" }, ]; return ( <div className="flex w-full max-w-xs flex-col gap-4"> {rows.map((row) => ( <div key={row.label} className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> {row.label} </span> <div ref={row.ref} data-overflow-fade className="flex items-center gap-0.5 overflow-x-auto no-scrollbar" > {views.map((name) => ( <span key={name} data-selected={ name === row.selected ? "" : undefined } className={cn( "flex h-7 shrink-0 items-center rounded-md px-2 text-13 whitespace-nowrap", name === row.selected ? "bg-muted font-medium text-foreground" : "text-muted-foreground", )} > {name} </span> ))} </div> </div> ))} </div> );}Through the Tabs component
Most rows should get the fade this way: TabsList scrollable attaches the hook, sets data-overflow-fade and hides the scrollbar. The selected tab starts in view.
Showing remittances for Northwind Freight.
import { Tabs, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import * as React from "react";export function WithTabs() { const [tab, setTab] = React.useState("remittances"); const tabs = [ { value: "overview", label: "Overview" }, { value: "invoices", label: "Invoices" }, { value: "payments", label: "Payments" }, { value: "remittances", label: "Remittances" }, { value: "documents", label: "Documents" }, { value: "tax-forms", label: "Tax forms" }, { value: "activity", label: "Activity" }, { value: "contacts", label: "Contacts" }, ]; return ( <div className="flex w-full max-w-sm flex-col gap-3"> <Tabs value={tab} onValueChange={(value) => setTab(String(value))}> <TabsList variant="line" scrollable aria-label="Northwind Freight" > {tabs.map((item) => ( <TabsTrigger key={item.value} value={item.value}> {item.label} </TabsTrigger> ))} </TabsList> </Tabs> <p className="text-13 text-muted-foreground"> Showing{" "} {tabs.find((item) => item.value === tab)?.label.toLowerCase()}{" "} for Northwind Freight. </p> </div> );}States#
| State | Treatment |
|---|---|
| Fits | Nothing is clipped: no attributes, no fade. |
| Clipped at the end | At the start of a long row: the right edge fades. |
| Clipped at the start | Scrolled to the end: the left edge fades. |
| Clipped on both sides | Scrolled partway: both edges fade. |
| Selection changed | The newly selected item scrolls into view smoothly, or instantly under reduced motion. |
Behavior#
- On attach, the selected item is scrolled into view instantly, then the edges are measured.
- Edges update on scroll (a passive listener) and on resize (ResizeObserver), with a 1px tolerance so subpixel widths don't leave a stray fade.
- A MutationObserver watches the subtree for added or removed items and for
aria-selectedchanges, then reveals the selection again. - The reveal leaves 24px between the selected item and the edge it was hidden behind.
- The fade is a CSS
mask-image, so it works on any background and doesn't add elements or block clicks. - The ref returns a cleanup function (React 19 callback ref cleanup), so observers and listeners go away with the element.
Do and don't#
data-overflow-fade on the same element as the ref.no-scrollbar and let the fade say there is more.Content#
- Keep tab and view labels to one or two words, so more of the row fits before it has to scroll.
- Counts trail the label in 12px Slate Meta tabular figures: Awaiting approval 37.
- Don't truncate labels with an ellipsis inside a scrolling row; let the row scroll.
Accessibility#
- The fade is visual only. Clipped items stay in the accessibility tree and in the tab order.
- Focusing a clipped item scrolls it into view natively; selecting it with the keyboard runs the hook's reveal too.
- Use real tab semantics (
role="tablist",role="tab",aria-selected) with arrow-key movement, or the Tabs component, which does it for you. - Reduced motion is respected: the reveal jumps instead of scrolling smoothly.
| Keys | Action |
|---|---|
| ArrowLeftArrowRight | In a tablist, moves the selection; the hook scrolls the new tab into view. |
| Tab | Moves focus into and out of the row. |
Design tokens#
| Token | Used for |
|---|---|
[data-overflow-fade] | globals.css: the mask-image gradients, keyed off the two state attributes |
2rem | Length of each fade |
no-scrollbar | Utility that hides the native scrollbar |
API reference#
useOverflowFade
useOverflowFade<T extends HTMLElement>(selectedSelector?: string), from @oration/canon/hooks/use-overflow-fade.
| Prop | Type | Default | Description |
|---|---|---|---|
selectedSelector | string | '[aria-selected="true"]' | Finds the item to keep in view. Matched on attach and whenever aria-selected or the item list changes. |
Returns
A callback ref for the scroller.
| Prop | Type | Default | Description |
|---|---|---|---|
ref | (node: T | null) => (() => void) | undefined | No default | Pass to the scroller's ref. Sets up and cleans up the observers. |
Data attributes
What you set and what the hook sets.
| Prop | Type | Default | Description |
|---|---|---|---|
data-overflow-fadeRequired | attribute | No default | Set by you. Opts the element into the fade styles. |
data-overflow-start | attribute | No default | Set by the hook while content is clipped on the left. |
data-overflow-end | attribute | No default | Set by the hook while content is clipped on the right. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The MutationObserver only watches aria-selected. A custom selectedSelector such as [data-active] is revealed on attach, but not when that attribute moves to another item.
The reveal inset is 24px but the fade is 32px, so the selected item's outer edge still sits inside the gradient at about 75% opacity.
The record view tabs use role="tab" buttons without arrow-key movement or tabIndex roving, so the tablist semantics are incomplete.
It assumes left to right: start means the left edge, and it reads scrollLeft as positive. In a right-to-left row, where scrollLeft runs negative, the attributes and fades come out wrong.