Use this page for the index of a collection people browse and narrow down, such as a workspace's campaigns, with the action that creates a new one in the header. People search by name, add filters to refine the list, and page through the results in a table. Use it when the collection is too large to scan in one screen.
For guidance on when and how to use it, read List page in Patterns.
Send messages automatically when people match a trigger you choose.
Name | Status | Trigger | Last sent | Messages sent |
|---|---|---|---|---|
| Welcome series | Running | Segment | Oct 8, 2026 | 48,213 |
| Abandoned cart reminder | Running | Event | Oct 8, 2026 | 12,904 |
| Trial ending soon | Running | Date | Oct 8, 2026 | 3,417 |
| Re-engage inactive users | Paused | Segment | Sep 22, 2026 | 21,560 |
| Onboarding checklist nudges | Running | Event | Oct 7, 2026 | 9,882 |
| Monthly product digest | Running | Date | Oct 1, 2026 | 64,110 |
| Webinar follow-up | Paused | Form submission | Sep 18, 2026 | 1,245 |
| Payment failed | Running | Event | Oct 8, 2026 | 742 |
| Annual plan renewal reminder | Running | Date | Oct 6, 2026 | 5,630 |
| Support satisfaction survey | Paused | Event | Sep 30, 2026 | 2,318 |
import {
Badge,
Button,
DataTable,
FormattedDateTime,
Page,
PageBody,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderTitle,
} from '@customerio/pluma-components/react';
import type { BadgeSemanticColor, DataTableFeatures, Filter, FilterValue } from '@customerio/pluma-components/react';
import { createColumnHelper } from '@tanstack/table-core';
type CampaignStatus = 'running' | 'paused' | 'draft' | 'archived';
type CampaignTrigger = 'segment' | 'event' | 'date' | 'form';
export interface Campaign {
id: string;
name: string;
status: CampaignStatus;
trigger: CampaignTrigger;
sentCount: number;
/** ISO 8601 timestamp, or `null` for a campaign that has not sent yet. */
lastSentAt: string | null;
}
const campaignStatuses: Record<CampaignStatus, { label: string; color: BadgeSemanticColor }> = {
running: { label: 'Running', color: 'success' },
paused: { label: 'Paused', color: 'caution' },
draft: { label: 'Draft', color: 'neutral' },
archived: { label: 'Archived', color: 'neutral' },
};
const campaignTriggers: Record<CampaignTrigger, string> = {
segment: 'Segment',
event: 'Event',
date: 'Date',
form: 'Form submission',
};
const campaignFilters: Filter[] = [
{
key: 'status',
label: 'Status',
type: 'checkbox',
options: [
{ label: 'Running', value: 'running' },
{ label: 'Paused', value: 'paused' },
{ label: 'Draft', value: 'draft' },
{ label: 'Archived', value: 'archived' },
],
},
{
key: 'trigger',
label: 'Trigger',
type: 'checkbox',
options: [
{ label: 'Segment', value: 'segment' },
{ label: 'Event', value: 'event' },
{ label: 'Date', value: 'date' },
{ label: 'Form submission', value: 'form' },
],
},
];
const numberFormatter = new Intl.NumberFormat();
const columnHelper = createColumnHelper<DataTableFeatures, Campaign>();
const columns = columnHelper.columns([
columnHelper.accessor('name', {
header: 'Name',
meta: { width: '2fr' },
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => {
const status = campaignStatuses[info.getValue()];
return (
<Badge color={status.color} showStatusIndicator={true}>
{status.label}
</Badge>
);
},
}),
columnHelper.accessor('trigger', {
header: 'Trigger',
cell: (info) => campaignTriggers[info.getValue()],
}),
columnHelper.accessor('lastSentAt', {
header: 'Last sent',
cell: (info) => <FormattedDateTime date={info.getValue()} format="medium" />,
}),
columnHelper.accessor('sentCount', {
header: 'Messages sent',
cell: (info) => numberFormatter.format(info.getValue()),
meta: { align: 'end' },
}),
]);
export interface ListWithFiltersProps {
/** The current page of campaigns that match the search and filters. */
campaigns: Campaign[];
/** How many campaigns match the search and filters, across all pages. */
totalCampaignCount: number;
/** The current page, starting at 1. */
page: number;
/** How many campaigns a page holds. */
pageSize: number;
/** The text in the search field. */
searchValue: string;
/** The filters applied to the list. */
filterValues: FilterValue[];
/** Whether the campaigns for the current search, filters and page are loading. */
isLoading: 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 filters to apply. Show the first page of their results. */
onFilterChange: (filterValues: FilterValue[]) => void;
/** Called with the page to show. */
onPageChange: (page: number) => void;
}
export function ListWithFilters({
campaigns,
totalCampaignCount,
page,
pageSize,
searchValue,
filterValues,
isLoading,
onCreate,
onSearchInput,
onSearch,
onFilterChange,
onPageChange,
}: ListWithFiltersProps) {
const handleFilterChange = (key: string, value: unknown) => {
const isApplied = filterValues.some((filter) => filter.key === key);
onFilterChange(
isApplied
? filterValues.map((filter) => (filter.key === key ? { ...filter, value } : filter))
: [...filterValues, { key, value }],
);
};
const handleFilterClear = (key: string) => {
onFilterChange(filterValues.filter((filter) => filter.key !== key));
};
const handleFilterClearAll = () => {
onFilterChange([]);
};
return (
<Page isFullScreen>
<PageHeader>
<PageHeaderTitle>Campaigns</PageHeaderTitle>
<PageHeaderDescription>
Send messages automatically when people match a trigger you choose.
</PageHeaderDescription>
<PageHeaderActions>
<Button variant="primary" icon="add" onClick={onCreate}>
Create campaign
</Button>
</PageHeaderActions>
</PageHeader>
<PageBody>
<DataTable
caption="Campaigns"
data={campaigns}
columns={columns}
isLoading={isLoading}
withSearch={true}
search={{
value: searchValue,
onSearchInput,
onSearch,
ariaLabel: 'Search campaigns',
placeholder: 'Search by name',
shouldSearchOnDebounce: 300,
shouldSearchOnClear: true,
}}
withFilters={true}
filters={{
filters: campaignFilters,
values: filterValues,
onChange: handleFilterChange,
onClear: handleFilterClear,
onClearAll: handleFilterClearAll,
}}
withPagination={true}
pagination={{
page,
pageSize,
totalItemCount: totalCampaignCount,
onPageChange,
}}
emptyState={{
icon: 'campaigns',
title: 'No campaigns found',
description: 'Try a different search, or remove a filter.',
}}
/>
</PageBody>
</Page>
);
}
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-filters <dest-dir> --framework reactThis recipe uses these components. Each one's docs page lists its props.