Examples
The examples use SampleCard, LoadingCard, Toolbar and useSelect from Example utils.
Minimal usage
Section titled “Minimal usage”CardWindow renders only the part of a large data set that fills the view. This addresses some common performance bottlenecks:
- It reduces the work, and the time, needed to render the initial view and to process updates.
- It reduces the memory footprint by not allocating DOM nodes that nobody sees.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard } from './shared';
const Minimal = () => { const data = range(10000); const cardRect = { width: 200, height: 120 }; return ( <div style={{ height: 300 }}> <CardWindow data={data} cardRect={cardRect}> {SampleCard} </CardWindow> </div> );};
export default Minimal;Required properties
Section titled “Required properties”CardWindow has three required properties. The type parameter is T extends any[].
| Name | Type | Description |
|---|---|---|
data |
T |
An array. CardWindow passes it to the children component. |
cardRect |
Rect |
The width and height of every card, in px. |
children |
React.ComponentType<CardProps<T>> |
The component that renders one card. It receives CardProps<T>. |
CardWindow infers T from data.
An inline children component and getKey receive data as that type, without annotations.
A card component typed as CardProps<T> for a T that data does not fit is a type error.
Styling
Section titled “Styling”root is passed to the root element of CardWindow, which is the scroll container.
By default, root.style has these values:
width:100%height:100%scrollbarGutter:stable
root.style overrides each of them. overflow stays auto, and root.style cannot override it.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard } from './shared';
const RootStyle = () => { const data = range(100); const cardRect = { width: 200, height: 120 }; const root = { style: { height: 300, border: '10px dashed var(--sl-color-accent)' } }; return ( <CardWindow data={data} cardRect={cardRect} root={root}> {SampleCard} </CardWindow> );};
export default RootStyle;container
Section titled “container”container is passed to the element inside the scroll container that holds the grid and sets the scroll height.
container.style cannot override width, height, paddingLeft, paddingRight, boxSizing, position and overflowX.
overflowX is clip, so a card or loading card wider than its column is cut at the sides, and the root never scrolls sideways.
The type of container.style leaves out overflow and overflowX.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard } from './shared';
const ContainerStyle = () => { const data = range(100); const cardRect = { width: 200, height: 120 }; const container = { style: { background: 'linear-gradient(65deg, #f13f79, #2196f3)' } }; return ( <div style={{ height: 300 }}> <CardWindow data={data} cardRect={cardRect} container={container}> {SampleCard} </CardWindow> </div> );};
export default ContainerStyle;Card positions
Section titled “Card positions”justifyContent and spacing place the cards.
justifyContent goes straight to CSS Grid justify-content.
start and end follow the writing direction, while left and right do not.
stretch grows the columns to fill the width.
Whatever the value, the last row always lines up under the columns above it.
spacing sets the gaps between the cards (x, y) and the space at the edges (top, bottom, left, right), 8px each by default.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard, Toolbar, useSelect } from './shared';
const justifyContentList = [ 'left', 'right', 'start', 'end', 'center', 'space-around', 'space-between', 'space-evenly', 'stretch',] as const;const spacingList = [0, 4, 8, 20, 50] as const;
const CardPositions = () => { const [justifyContent, justifyContentSelect] = useSelect('justifyContent', justifyContentList, 7); const [x, xSelect] = useSelect('x', spacingList, 2); const [y, ySelect] = useSelect('y', spacingList, 2); const [top, topSelect] = useSelect('top', spacingList, 2); const [bottom, bottomSelect] = useSelect('bottom', spacingList, 2); const [left, leftSelect] = useSelect('left', spacingList, 2); const [right, rightSelect] = useSelect('right', spacingList, 2); const data = range(10); const cardRect = { width: 200, height: 120 }; const spacing = { x, y, top, bottom, left, right }; const props = { data, cardRect, justifyContent, spacing }; return ( <div> <Toolbar>{justifyContentSelect}</Toolbar> <Toolbar> <span>spacing:</span> {xSelect} {ySelect} {topSelect} {bottomSelect} {leftSelect} {rightSelect} </Toolbar> <div style={{ height: 400 }}> <CardWindow {...props}>{SampleCard}</CardWindow> </div> </div> );};
export default CardPositions;Columns
Section titled “Columns”Automatic columns
Section titled “Automatic columns”The browser fits as many columns as the width allows, and CardWindow follows the width through ResizeObserver.
Set the size of the element around CardWindow with CSS, and the columns adjust.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard, Toolbar, useSelect } from './shared';
const AutoColumns = () => { const [width, widthSelect] = useSelect('width', ['100%', '75%', '50%'] as const); const data = range(10000); const cardRect = { width: 200, height: 120 }; return ( <div> <Toolbar>{widthSelect}</Toolbar> <div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{ width, height: 300 }}> <CardWindow data={data} cardRect={cardRect}> {SampleCard} </CardWindow> </div> </div> </div> );};
export default AutoColumns;Capping the columns
Section titled “Capping the columns”To cap the columns, cap the width of the element around CardWindow.
For N columns that width is spacing.left + spacing.right + N × cardRect.width + (N − 1) × spacing.x, plus the scrollbar width where scrollbars are classic.
The example measures that scrollbar gutter through ref, which receives the scroll container element.
It goes well with justifyContent="stretch".
import { useLayoutEffect, useRef, useState } from 'react';
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard, Toolbar, useSelect } from './shared';
const CapColumns = () => { const [columns, columnsSelect] = useSelect('columns', [1, 2, 3, 4, 5] as const, 2); const data = range(100); // Narrow cards, so that five columns fit the width of this page. const cardRect = { width: 100, height: 120 }; const spacing = { x: 8, left: 8, right: 8 }; // The root reserves a scrollbar gutter: 0px with overlay scrollbars, the scrollbar width with classic ones. const ref = useRef<HTMLDivElement>(null); const [gutter, setGutter] = useState(0); useLayoutEffect(() => { const el = ref.current; if (el) setGutter(el.offsetWidth - el.clientWidth); }, []); const width = spacing.left + spacing.right + columns * cardRect.width + (columns - 1) * spacing.x + gutter; return ( <div> <Toolbar>{columnsSelect}</Toolbar> <div style={{ width, maxWidth: '100%', height: 300 }}> <CardWindow ref={ref} data={data} cardRect={cardRect} spacing={spacing} justifyContent="stretch"> {SampleCard} </CardWindow> </div> </div> );};
export default CapColumns;Overscanning
Section titled “Overscanning”overScanPx renders cards outside of the visible area. It helps for two reasons:
- Overscanning by one row allows the tab key to focus on the next item, which is not yet visible.
- Overscanning slightly can reduce or prevent a flash of empty space when the user starts scrolling.
Overscanning too much can hurt performance. By default, CardWindow overscans by 200px.
Open the element inspector of your browser and scroll to see the cards come and go.
With a larger overScanPx, more rows stay mounted.
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard, Toolbar, useSelect } from './shared';
const OverScan = () => { const [overScanPx, overScanPxSelect] = useSelect('overScanPx', [0, 200, 1000, 2000] as const, 1); const data = range(100); const cardRect = { width: 200, height: 120 }; return ( <div> <Toolbar>{overScanPxSelect}</Toolbar> <div style={{ height: 200 }}> <CardWindow data={data} cardRect={cardRect} overScanPx={overScanPx}> {SampleCard} </CardWindow> </div> </div> );};
export default OverScan;Scroll event
Section titled “Scroll event”onScroll is called at most once per animation frame after a scroll.
It receives OnScrollProps: direction, offset, updateWasRequested and indexesOfVisible.
updateWasRequested is true when the scroll moved the rendered rows, whether or not code started the scroll.
indexesOfVisible lists the cards in the rows that show more than thresholdOfVisible of the card height, 0.5 by default.
import { useState } from 'react';
import { CardWindow, type OnScrollProps, range } from '@annetaan/card-window';
import { SampleCard, Toolbar } from './shared';
const ScrollEvent = () => { const [last, setLast] = useState<OnScrollProps | null>(null); const data = range(10000); const cardRect = { width: 200, height: 120 }; const visible = last?.indexesOfVisible ?? []; return ( <div> <Toolbar> {last === null ? ( <span>Scroll the cards</span> ) : ( <> <span>direction: {last.direction}</span> <span>offset: {Math.round(last.offset)}</span> <span>updateWasRequested: {String(last.updateWasRequested)}</span> <span> indexesOfVisible: {visible.length === 0 ? 'none' : `${visible[0]}–${visible[visible.length - 1]}`} </span> </> )} </Toolbar> <div style={{ height: 300 }}> <CardWindow data={data} cardRect={cardRect} onScroll={setLast}> {SampleCard} </CardWindow> </div> </div> );};
export default ScrollEvent;Scroll container ref
Section titled “Scroll container ref”ref receives the scroll container element, so useRef<HTMLDivElement>(null) fits it.
scrollTo, scrollTop and the rest of the element work on it directly.
import { useRef } from 'react';
import { CardWindow, range } from '@annetaan/card-window';
import { SampleCard, Toolbar } from './shared';
const ScrollRef = () => { const ref = useRef<HTMLDivElement>(null); const data = range(1000); const cardRect = { width: 200, height: 120 }; const toTop = () => ref.current?.scrollTo({ top: 0, behavior: 'smooth' }); const toEnd = () => { const el = ref.current; el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); }; return ( <div> <Toolbar> <button type="button" onClick={toTop}> Back to top </button> <button type="button" onClick={toEnd}> To the end </button> </Toolbar> <div style={{ height: 300 }}> <CardWindow ref={ref} data={data} cardRect={cardRect}> {SampleCard} </CardWindow> </div> </div> );};
export default ScrollRef;Infinite loading
Section titled “Infinite loading”You manage the pending state yourself.
Set loadMore only while you can accept a call.
loadMore is called when the end of the list comes within overScanPx of the view, and again after data grows while the end is still that close.
It is not called on every render.
Loading has two types, card and row.
type: 'card'
Section titled “type: 'card'”The loading cards follow the last card in the grid.
count sets how many, 10 by default.
import { useEffect, useState } from 'react';
import { CardWindow, type Loading, range } from '@annetaan/card-window';
import { LoadingCard, SampleCard } from './shared';
const LoadingCards = () => { const [{ data, pending }, setState] = useState({ data: range(10), pending: false });
useEffect(() => { if (!pending) return undefined; const timer = window.setTimeout(() => setState((s) => ({ data: range(s.data.length + 10), pending: false })), 1000); return () => window.clearTimeout(timer); }, [pending]);
const cardRect = { width: 200, height: 120 }; const next = data.length < 100; // Set the loadMore function only if you can call it. const loadMore = pending ? undefined : () => setState((s) => ({ ...s, pending: true })); const loading: Loading | undefined = next ? { type: 'card', LoadingComponent: LoadingCard, loadMore } : undefined; return ( <div style={{ height: 300 }}> <CardWindow data={data} cardRect={cardRect} loading={loading}> {SampleCard} </CardWindow> </div> );};
export default LoadingCards;type: 'row'
Section titled “type: 'row'”height is required.
The loading row renders once, centered below the last row, and only when the last row is in range.
import { useEffect, useState } from 'react';
import { CardWindow, type Loading, range } from '@annetaan/card-window';
import { LoadingCard, SampleCard } from './shared';
const LoadingRow = () => { const [{ data, pending }, setState] = useState({ data: range(10), pending: false });
useEffect(() => { if (!pending) return undefined; const timer = window.setTimeout(() => setState((s) => ({ data: range(s.data.length + 10), pending: false })), 1000); return () => window.clearTimeout(timer); }, [pending]);
const cardRect = { width: 200, height: 120 }; const next = data.length < 100; // Set the loadMore function only if you can call it. const loadMore = pending ? undefined : () => setState((s) => ({ ...s, pending: true })); const loading: Loading | undefined = next ? { type: 'row', LoadingComponent: LoadingCard, height: 80, loadMore } : undefined; return ( <div style={{ height: 300 }}> <CardWindow data={data} cardRect={cardRect} loading={loading}> {SampleCard} </CardWindow> </div> );};
export default LoadingRow;