High-performance trellis/gallery layout for React with paginated grids, virtualized scrolling, and floating detail panels.
Live Playground — try every option interactively.
- Two display modes — paginated grid or virtualized infinite scroll (powered by react-window)
- Responsive auto layout — computes rows and columns from minimum item dimensions
- Manual layout — fixed rows × columns for precise control
- Floating detail panels — open panels from the built-in corner triangle trigger, optional custom activation callback, and imperative ref controls (powered by Zag.js)
- Viewport or container panel boundaries — choose full-screen movement bounds or constrain to a specific container via
panelBoundary - Top-layer panel rendering — panels are rendered in a full-screen portal layer so consumer app z-index/stacking contexts do not cover them
- Controlled & uncontrolled pagination — manage page state externally or let the component handle it
- Custom pagination UI — provide your own render function, use the built-in default, or hide controls entirely
- Draggable pagination overlay — reposition the pagination bar by dragging
- Go to item by callback — navigate to a target match and highlight all callback matches across pages or in scroll mode, with configurable highlight color and auto-clear duration
- Keyboard accessible — corner trigger is keyboard-focusable; custom activation callbacks can include keyboard combos
- SSR-safe — uses isomorphic layout effects for server-side rendering compatibility
- Fully typed — written in TypeScript with all types exported
- Tree-shakable — ships ESM + CJS with no side effects
bun add react-trellis-galleryor:
npm install react-trellis-galleryPeer dependencies: react >= 19 and react-dom >= 19.
import { TrellisGallery } from 'react-trellis-gallery'
const items = Array.from({ length: 20 }, (_, i) => ({
id: i,
title: `Item ${i + 1}`,
}))
function App() {
return (
<div style={{ width: 800, height: 600 }}>
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 180, minItemHeight: 140 }}
pagination={{ mode: 'uncontrolled' }}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div><h2>{item.title}</h2><p>Detail view</p></div>}
/>
</div>
)
}The container must have explicit width and height — the gallery fills its parent.
For large datasets, switch to scroll mode which virtualizes rows via react-window:
<TrellisGallery
items={items}
mode="scroll"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
overscanCount={2}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>Set an exact grid of 3 columns × 2 rows per page:
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'manual', rows: 2, cols: 3 }}
gap={8}
pagination={{ mode: 'uncontrolled' }}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>Manage page state yourself:
const [page, setPage] = useState(0)
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
pagination={{
mode: 'controlled',
page,
onPageChange: setPage,
position: 'bottom',
align: 'center',
}}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>Replace the built-in pagination UI with your own:
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
pagination={{
mode: 'uncontrolled',
renderControl: (vm) => (
<div>
<button onClick={vm.prev} disabled={!vm.hasPrev}>Prev</button>
<span>{vm.currentPage + 1} / {vm.totalPages}</span>
<button onClick={vm.next} disabled={!vm.hasNext}>Next</button>
</div>
),
}}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>Pass renderControl: false to hide pagination controls entirely.
Customize the floating panel header for each item:
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
pagination={{ mode: 'uncontrolled' }}
panelTitle={(item) => item.title}
renderPanelHeader={(item, api) => (
<div>
<span {...api.titleProps}>{item.title}</span>
<button onClick={api.togglePin}>{api.isPinned ? 'Unpin' : 'Pin'}</button>
<button onClick={api.close}>Close</button>
</div>
)}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>By default, panels use viewport boundaries and render in a top-level portal layer:
<TrellisGallery
items={items}
mode="scroll"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
panelBoundary="viewport" // default
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>To constrain panel drag/maximize bounds to a specific container:
const containerRef = useRef<HTMLDivElement | null>(null)
<div ref={containerRef} style={{ width: 900, height: 600 }}>
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
pagination={{ mode: 'uncontrolled' }}
panelBoundary={containerRef}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>
</div>panelBoundary only controls movement/maximize boundaries. Panel DOM is still rendered in a full-screen portal layer for topmost stacking.
By default there is no keyboard/mouse shortcut on the cell body. Pass cellActivation to define your own logic:
<TrellisGallery
items={items}
mode="pagination"
layout={{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }}
pagination={{ mode: 'uncontrolled' }}
cellActivation={(event) => {
if (event.type === 'dblclick') return true // double-click
return event.type === 'click' && event.shiftKey // shift + click
}}
renderItem={(item) => <div>{item.title}</div>}
renderExpandedItem={(item) => <div>{item.title}</div>}
/>The cell indicator is enabled by default. Disable it or customize styles:
// Disable the indicator entirely
<TrellisGallery cellIndicator={false} {...props} />
// Customize indicator styles
<TrellisGallery
{...props}
cellIndicator={{
borderColor: 'rgba(34, 197, 94, 0.45)',
triangleColor: 'rgba(34, 197, 94, 0.2)',
triangleSize: 26,
}}
/>Use the ref handle when you want to open/close panels from external UI or route state:
import { useRef } from 'react'
import { TrellisGallery, type TrellisGalleryHandle } from 'react-trellis-gallery'
function GalleryWithExternalActions({ items }) {
const galleryRef = useRef<TrellisGalleryHandle<typeof items[number]> | null>(null)
return (
<>
<button onClick={() => galleryRef.current?.panels.open((item) => item.title.includes('Featured'))}>
Open Featured
</button>
<button onClick={() => galleryRef.current?.panels.close((item) => item.archived)}>
Close Archived
</button>
<TrellisGallery ref={galleryRef} items={items} {...otherProps} />
</>
)
}Pass a callback to match items, navigate to the target match, and highlight all matches. Works in both pagination and scroll modes.
Call goToItem on the ref handle:
import { useRef } from 'react'
import { TrellisGallery, type TrellisGalleryHandle } from 'react-trellis-gallery'
function SearchableGallery({ items }) {
const galleryRef = useRef<TrellisGalleryHandle<typeof items[number]> | null>(null)
const handleSearch = (query: string) => {
const result = galleryRef.current?.goToItem(
(item) => item.title.toLowerCase().includes(query.toLowerCase()),
{ highlightDuration: 5000 },
)
console.log(result) // { found, page, matchCount, matchIndices, targetIndex }
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} placeholder="Search..." />
<button onClick={() => galleryRef.current?.clearHighlights()}>Clear</button>
<TrellisGallery ref={galleryRef} items={items} {...otherProps} />
</>
)
}Customizing the highlight appearance:
<TrellisGallery
highlightColor="#10b981"
highlightClassName="my-highlight"
{...otherProps}
/>You can also override the CSS custom properties or target the data-rtg-highlighted attribute directly:
[data-rtg-cell][data-rtg-highlighted]::before {
box-shadow: 0 0 0 3px hotpink;
}Use the hooks directly for full control over rendering:
import { useTrellisGallery, useCellInteraction } from 'react-trellis-gallery'
function Cell({ item, onOpen }) {
const interaction = useCellInteraction({
onActivate: onOpen,
activationCallback: (event) => event.type === 'dblclick',
})
return <div {...interaction}>{item.title}</div>
}
function CustomGallery({ items }) {
const { containerRef, layout, pagination, panels } = useTrellisGallery({
items,
mode: 'pagination',
layout: { type: 'auto', minItemWidth: 200, minItemHeight: 150 },
pagination: { mode: 'uncontrolled' },
})
const pageItems = items.slice(pagination.startIndex, pagination.endIndex)
return (
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${layout.cols}, ${layout.cellWidth}px)`,
gridTemplateRows: `repeat(${layout.rows}, ${layout.cellHeight}px)`,
}}>
{pageItems.map((item, i) => (
<Cell
key={i}
item={item}
onOpen={() => panels.open(pagination.startIndex + i)}
/>
))}
</div>
</div>
)
}The main component. Renders a grid with optional pagination and floating panels.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
T[] |
— | Array of data items to display |
mode |
'pagination' | 'scroll' |
— | Display mode |
layout |
LayoutConfig |
— | Layout strategy (see below) |
gap |
number |
0 |
Gap between cells in pixels |
overscanCount |
number |
1 |
Extra rows rendered outside the viewport (scroll mode) |
pagination |
PaginationConfig |
— | Pagination options (required for pagination mode) |
panelDefaults |
Partial<FloatingPanelDefaults> |
{ size: { width: 600, height: 400 }, minSize: { width: 300, height: 180 } } |
Default floating panel dimensions |
panelBoundary |
'viewport' | RefObject<HTMLElement | null> |
'viewport' |
Panel drag/maximize boundary. Panels still render in a full-screen portal layer |
renderItem |
(item: T, index: number) => ReactNode |
— | Renders each grid cell |
renderExpandedItem |
(item: T, index: number) => ReactNode |
— | Renders the content inside a floating panel |
panelTitle |
(item: T, index: number) => ReactNode |
— | Panel title text |
renderPanelHeader |
(item: T, api: PanelHeaderAPI) => ReactNode |
— | Custom panel header (overrides default) |
onPanelOpen |
(item: T, index: number) => void |
— | Called when a panel opens |
onPanelClose |
(item: T, index: number) => void |
— | Called when a panel closes |
cellIndicator |
false | CellIndicatorConfig |
Enabled | Shows per-cell border + corner triangle trigger; pass false to disable |
cellActivation |
(event: CellActivationEvent) => boolean |
— | Optional callback to open panels from custom click/double-click/keyboard combos |
highlightColor |
string |
'#0078d4' |
CSS color for the highlight border |
highlightDuration |
number |
3600 |
Auto-clear delay in ms. 0 = keep indefinitely |
highlightClassName |
string |
— | Additional CSS class applied to highlighted cells |
ref |
Ref<TrellisGalleryHandle<T>> |
— | Imperative handle for panels and go-to-item |
className |
string |
— | CSS class for the container |
style |
CSSProperties |
— | Inline styles for the container |
{
borderColor?: string
triangleColor?: string
triangleSize?: number
}Exposed from ref on <TrellisGallery>:
{
panels: {
open(callback: (item: T) => boolean): void
close(callback: (item: T) => boolean): void
closeAll(): void
closeUnpinned(): void
isOpen(callback: (item: T) => boolean): boolean
openPanels: PanelState[]
}
goToItem(callback: (item: T) => boolean, options?: GoToItemOptions): GoToItemResult
clearHighlights(): void
}{
highlightDuration?: number // ms before auto-clear. 0 = keep indefinitely. Overrides the component prop.
target?: 'first' | 'last' | number // which match to navigate to (default: 'first')
}{
found: boolean // whether any items matched
page: number // page index of the target item
matchIndices: number[] // indices of all matching items
matchCount: number // total number of matches
targetIndex: number // index of the item navigated to (-1 if not found)
}Auto layout — calculates the grid from minimum item dimensions:
{ type: 'auto', minItemWidth: 200, minItemHeight: 150 }Manual layout — fixed grid dimensions:
{ type: 'manual', rows: 3, cols: 4 }Uncontrolled — the component manages page state internally:
{
mode: 'uncontrolled',
defaultPage?: number, // initial page (0-indexed)
onPageChange?: (page) => {}, // notified on change
renderControl?: (vm) => {}, // custom UI, or false to hide
position?: 'top' | 'bottom', // default: 'bottom'
align?: 'start' | 'center' | 'end', // default: 'center'
draggable?: boolean, // make the overlay draggable
label?: ReactNode | (vm) => ReactNode,
}Controlled — you own the page state:
{
mode: 'controlled',
page: number, // current page (0-indexed)
onPageChange?: (page) => {}, // update your state here
// ...same options as uncontrolled
}The view model passed to renderControl and available via useTrellisPaginationContext():
| Property | Type | Description |
|---|---|---|
currentPage |
number |
Current page index (0-based) |
totalPages |
number |
Total number of pages |
totalItems |
number |
Total number of items |
itemsPerPage |
number |
Items fitting on one page |
startIndex |
number |
First visible item index |
endIndex |
number |
Index after last visible item |
hasNext |
boolean |
Whether a next page exists |
hasPrev |
boolean |
Whether a previous page exists |
goToPage(page) |
function |
Navigate to a specific page |
next() |
function |
Go to the next page |
prev() |
function |
Go to the previous page |
Passed to renderPanelHeader for controlling individual panels:
| Property | Type | Description |
|---|---|---|
close() |
function |
Close the panel |
pin() / unpin() / togglePin() |
function |
Pin/unpin the panel |
titleProps |
Record<string, unknown> |
Props to spread on your title element to keep aria-labelledby wiring intact in custom headers |
isPinned |
boolean |
Whether the panel is pinned |
minimize() / maximize() / restore() |
function |
Window state controls |
isMinimized / isMaximized |
boolean |
Current window state |
Returned by useTrellisPanels() and available on useTrellisGallery().panels:
| Property | Type | Description |
|---|---|---|
openPanels |
PanelState[] |
Currently open panels |
open(itemIndex) |
function |
Open a panel for an item |
activate(id) |
function |
Bring a panel to front |
close(id) |
function |
Close a panel by ID |
closeAll() |
function |
Close all panels |
closeUnpinned() |
function |
Close all unpinned panels |
togglePin(id) |
function |
Toggle pin state |
isOpen(itemIndex) |
function |
Check if an item's panel is open |
| Hook | Description |
|---|---|
useTrellisGallery(options) |
All-in-one hook returning containerRef, containerSize, layout, pagination, and panels |
useTrellisLayout(containerSize, itemCount, config, gap?) |
Computes grid layout from container dimensions |
useTrellisPagination(layout, totalItems, config) |
Manages pagination state and navigation |
useTrellisPanels(callbacks?) |
Manages floating panel open/close/pin state |
useTrellisHighlight(options) |
Manages highlight state, match scanning, navigation, and auto-clear. Accepts items, itemsPerPage, navigate, and defaultDuration. |
useCellInteraction({ onActivate, activationCallback }) |
Returns cell props for optional activation. When activationCallback is omitted it returns an empty prop object. |
useContainerSize(ref) |
Tracks element dimensions via ResizeObserver |
useTrellisPaginationContext() |
Reads pagination state from context (for child components of TrellisGallery) |
| Function | Description |
|---|---|
calculateLayout(width, height, itemCount, config, gap) |
Pure function that computes grid layout. Returns LayoutResult with rows, cols, cellWidth, cellHeight, itemsPerPage, totalPages. |
fitGrid(itemCount, maxRows, maxCols) |
Finds the smallest grid (rows × cols) that fits itemCount items within bounds. Prefers fewer rows, then fewer columns. |
clampPage(page, totalPages) |
Clamps a page index to the valid range [0, totalPages - 1]. |
| Property | Default | Description |
|---|---|---|
--rtg-border-radius |
10px |
Border radius for cells and highlight |
--rtg-highlight-color |
#0078d4 |
Highlight border color (also settable via highlightColor prop) |
--rtg-highlight-duration-ms |
1200ms |
CSS transition speed for the highlight fade in/out |
--rtg-cell-border-color |
rgba(0, 0, 0, 0.15) |
Hover border color on cells |
--rtg-overlay-gradient-start |
rgba(0, 0, 0, 0.15) |
Pagination overlay gradient start color |
--rtg-bg |
#ffffff |
Floating panel background color |
--rtg-fg |
#1a1a1a |
Floating panel text color |
--rtg-shadow |
0 4px 20px rgba(0, 0, 0, 0.08) |
Floating panel shadow |
--rtg-border-color |
rgba(0, 0, 0, 0.12) |
Panel/button border color |
--rtg-header-border-color |
rgba(0, 0, 0, 0.08) |
Panel header bottom border color |
--rtg-button-hover-bg |
rgba(0, 0, 0, 0.06) |
Header icon button hover background |
--rtg-button-active-bg |
rgba(0, 0, 0, 0.1) |
Header icon button active background |
--rtg-button-disabled-opacity |
0.35 |
Header icon button disabled opacity |
Requires browsers with ResizeObserver support (all modern browsers). The library uses useIsomorphicLayoutEffect for SSR safety.