List with drawer detail

Page recipe

Use this page for a collection, such as a workspace's segments, where people often need a quick look at one item without losing their place in the list. Selecting an item's name opens a drawer beside the list with that item's details and an action to edit it. Use it when the details fit in a short summary; when people need the item's full history or tabs, link to a detail page instead.


Preview

Segments

1–10 of 27
Segments
Name
Type
People
Last updated
Data-driven12,403Oct 8, 2026
Data-driven1,120Oct 7, 2026
Manual318Oct 2, 2026
Data-driven48,213Oct 8, 2026
Data-driven64,110Sep 30, 2026
Data-driven742Sep 22, 2026
Manual1,245Oct 1, 2026
Data-driven2,318Oct 6, 2026
Data-driven586Oct 5, 2026
Manual47Oct 8, 2026
1–10 of 27

Source

import {
	Button,
	DataTable,
	DescriptionList,
	DescriptionListDetails,
	DescriptionListTerm,
	Drawer,
	DrawerBody,
	DrawerFooter,
	FormattedDateTime,
	Link,
	Page,
	PageBody,
	PageHeader,
	PageHeaderActions,
	PageHeaderTitle,
	PageSection,
} from '@customerio/pluma-components/react';
import type { DataTableFeatures } from '@customerio/pluma-components/react';
import { createColumnHelper } from '@tanstack/table-core';
import { useMemo } from 'react';

type SegmentType = 'data-driven' | 'manual';

export interface Segment {
	id: string;
	name: string;
	description: string;
	type: SegmentType;
	peopleCount: number;
	createdBy: string;
	/** ISO 8601 timestamp. */
	createdAt: string;
	/** ISO 8601 timestamp. */
	updatedAt: string;
}

const segmentTypes: Record<SegmentType, string> = {
	'data-driven': 'Data-driven',
	manual: 'Manual',
};

const numberFormatter = new Intl.NumberFormat();

const columnHelper = createColumnHelper<DataTableFeatures, Segment>();

export interface ListWithDrawerDetailProps {
	/** The current page of segments that match the search. */
	segments: Segment[];
	/** How many segments match the search, across all pages. */
	totalSegmentCount: number;
	/** The current page, starting at 1. */
	page: number;
	/** How many segments a page holds. */
	pageSize: number;
	/** The text in the search field. */
	searchValue: string;
	/** Whether the segments for the current search and page are loading. */
	isLoading: boolean;
	/**
	 * The segment the drawer shows, or `null` before one has been opened. Keep it
	 * set after the drawer closes, so the drawer keeps its content while it slides away.
	 */
	drawerSegment: Segment | null;
	/** Whether the drawer is open. */
	isDrawerOpen: boolean;
	/** Called when the create action is activated. */
	onCreate: () => void;
	/** Called on every edit of the search field. */
	onSearchInput: (value: string) => void;
	/** Called with the search to run. Show the first page of its results. */
	onSearch: (value: string) => void;
	/** Called with the page to show. */
	onPageChange: (page: number) => void;
	/** Called with the segment whose name was activated. Show it in the drawer and open it. */
	onOpenSegment: (segment: Segment) => void;
	/** Called when the drawer asks to close. */
	onCloseDrawer: () => void;
	/** Called with the segment to edit. */
	onEditSegment: (segment: Segment) => void;
}

export function ListWithDrawerDetail({
	segments,
	totalSegmentCount,
	page,
	pageSize,
	searchValue,
	isLoading,
	drawerSegment,
	isDrawerOpen,
	onCreate,
	onSearchInput,
	onSearch,
	onPageChange,
	onOpenSegment,
	onCloseDrawer,
	onEditSegment,
}: ListWithDrawerDetailProps) {
	const columns = useMemo(
		() =>
			columnHelper.columns([
				columnHelper.accessor('name', {
					header: 'Name',
					cell: (info) => (
						<Link onClick={() => onOpenSegment(info.row.original)} truncate={true}>
							{info.getValue()}
						</Link>
					),
					meta: { width: '2fr', minWidth: 200 },
				}),
				columnHelper.accessor('type', {
					header: 'Type',
					cell: (info) => segmentTypes[info.getValue()],
				}),
				columnHelper.accessor('peopleCount', {
					header: 'People',
					cell: (info) => numberFormatter.format(info.getValue()),
					meta: { align: 'end' },
				}),
				columnHelper.accessor('updatedAt', {
					header: 'Last updated',
					cell: (info) => <FormattedDateTime date={info.getValue()} format="medium" />,
				}),
			]),
		[onOpenSegment],
	);

	return (
		<Page isFullScreen>
			<PageHeader>
				<PageHeaderTitle>Segments</PageHeaderTitle>
				<PageHeaderActions>
					<Button variant="primary" icon="add" onClick={onCreate}>
						Create segment
					</Button>
				</PageHeaderActions>
			</PageHeader>
			<PageBody>
				<PageSection>
					<DataTable
						caption="Segments"
						data={segments}
						columns={columns}
						isLoading={isLoading}
						withSearch={true}
						search={{
							value: searchValue,
							onSearchInput,
							onSearch,
							ariaLabel: 'Search segments',
							placeholder: 'Search by name',
							shouldSearchOnDebounce: 300,
							shouldSearchOnClear: true,
						}}
						withPagination={true}
						pagination={{
							page,
							pageSize,
							totalItemCount: totalSegmentCount,
							onPageChange,
						}}
						emptyState={
							searchValue === ''
								? {
										icon: 'segment',
										title: 'No segments yet',
										description: 'Create a segment to group people by what they do and who they are.',
										actions: [{ label: 'Create segment', variant: 'primary', onClick: onCreate }],
									}
								: {
										icon: 'search',
										title: 'No segments match your search',
										description: 'Try a different name, or clear the search to see every segment.',
									}
						}
					/>
				</PageSection>
			</PageBody>
			{drawerSegment !== null ? (
				<Drawer isOpen={isDrawerOpen} onClose={onCloseDrawer} title={drawerSegment.name}>
					<DrawerBody>
						<DescriptionList layout="horizontal">
							<DescriptionListTerm>Description</DescriptionListTerm>
							<DescriptionListDetails>{drawerSegment.description}</DescriptionListDetails>

							<DescriptionListTerm>Type</DescriptionListTerm>
							<DescriptionListDetails>{segmentTypes[drawerSegment.type]}</DescriptionListDetails>

							<DescriptionListTerm>People</DescriptionListTerm>
							<DescriptionListDetails>{numberFormatter.format(drawerSegment.peopleCount)}</DescriptionListDetails>

							<DescriptionListTerm>Created by</DescriptionListTerm>
							<DescriptionListDetails>{drawerSegment.createdBy}</DescriptionListDetails>

							<DescriptionListTerm>Created</DescriptionListTerm>
							<DescriptionListDetails>
								<FormattedDateTime date={drawerSegment.createdAt} format="medium" />
							</DescriptionListDetails>

							<DescriptionListTerm>Last updated</DescriptionListTerm>
							<DescriptionListDetails>
								<FormattedDateTime date={drawerSegment.updatedAt} format="medium" />
							</DescriptionListDetails>
						</DescriptionList>
					</DrawerBody>
					<DrawerFooter>
						<Button onClick={onCloseDrawer}>Close</Button>
						<Button variant="primary" onClick={() => onEditSegment(drawerSegment)}>
							Edit segment
						</Button>
					</DrawerFooter>
				</Drawer>
			) : null}
		</Page>
	);
}

Scaffold

Copy this recipe into your app with the Pluma CLI. Replace <dest-dir> with the folder the file goes in, and add --name <Name> to rename the component and its file.

pluma-cli scaffold list-with-drawer-detail <dest-dir> --framework react

Components

This recipe uses these components. Each one's docs page lists its props.

On this page