Skip to content

Overflow fade

Fades the clipped edge of a horizontal row over 2rem, only on the side that is clipped.

Status
Beta
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useOverflowFade } from "@oration/canon/hooks/use-overflow-fade";
packages/canon/src/hooks/use-overflow-fade.ts
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 scrollable already 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

Clipped content shows its edge. Horizontal tab and view rows fade the clipped side over 2rem, and the last pinned grid column casts its edge shadow only once the grid scrolls sideways.

Anatomy#

PaidScheduledAwaiting approvalExceptionsOn hold
  1. Scroller. The element that takes the ref: overflow-x-auto no-scrollbar plus the data-overflow-fade attribute.
  2. Start fade. Drawn while data-overflow-start is set, once the row has scrolled more than 1px.
  3. End fade. Drawn while data-overflow-end is set, until the row reaches its last pixel. Both set means a fade on each side.
  4. 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].

Clipped at the end
OverviewInvoicesPaymentsRemittancesDocumentsTax formsActivityNotes
Clipped on both sides
OverviewInvoicesPaymentsRemittancesDocumentsTax formsActivityNotes
Clipped at the start
OverviewInvoicesPaymentsRemittancesDocumentsTax formsActivityNotes
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#

States
StateTreatment
FitsNothing is clipped: no attributes, no fade.
Clipped at the endAt the start of a long row: the right edge fades.
Clipped at the startScrolled to the end: the left edge fades.
Clipped on both sidesScrolled partway: both edges fade.
Selection changedThe 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-selected changes, 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#

All invoicesAwaiting approvalScheduledPaid
Do. Put data-overflow-fade on the same element as the ref.
All invoicesAwaiting approvalScheduledPaid
Don't. Attach the ref alone. The attributes are tracked, but the row ends in a hard cut through a label.
OverviewInvoicesPayments
Do. Let the hook fade only the side that is clipped.
OverviewInvoicesPayments
Don't. Mask both edges permanently, so short rows fade labels that aren't clipped at all.
Do. Hide the native scrollbar with no-scrollbar and let the fade say there is more.
Don't. Leave a scrollbar under a row of tabs, where it reads as a second, unrelated control.

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.
Keyboard interactions
KeysAction
ArrowLeftArrowRightIn a tablist, moves the selection; the hook scrolls the new tab into view.
TabMoves focus into and out of the row.

Design tokens#

Design tokens
TokenUsed for
[data-overflow-fade]globals.css: the mask-image gradients, keyed off the two state attributes
2remLength of each fade
no-scrollbarUtility that hides the native scrollbar

API reference#

useOverflowFade

useOverflowFade<T extends HTMLElement>(selectedSelector?: string), from @oration/canon/hooks/use-overflow-fade.

Props of useOverflowFade
PropTypeDefaultDescription
selectedSelectorstring'[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.

Props of Returns
PropTypeDefaultDescription
ref(node: T | null) => (() => void) | undefinedNo defaultPass to the scroller's ref. Sets up and cleans up the observers.

Data attributes

What you set and what the hook sets.

Props of Data attributes
PropTypeDefaultDescription
data-overflow-fadeRequiredattributeNo defaultSet by you. Opts the element into the fade styles.
data-overflow-startattributeNo defaultSet by the hook while content is clipped on the left.
data-overflow-endattributeNo defaultSet 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.