Examples
Multi-select cell
Use this when one field needs a small set of labels, tags, or categories without expanding into a full editor.
Interactive cell
Live preview
Build the React component
The main job here is summarizing several values into one compact surface and exposing a small add/remove interaction.
import { Check, Plus } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
type Tag = {
id: string
name: string
color: string
}
export function MultiSelectCell({
value,
options,
onChange,
}: {
value: Tag[]
options: Tag[]
onChange: (value: Tag[]) => void
}) {
const selectedIds = new Set(value.map((tag) => tag.id))
return (
<Popover>
<PopoverTrigger asChild>
<button className="flex min-h-8 w-full flex-wrap items-center gap-1 rounded-md px-1.5 py-1 text-left hover:bg-accent" type="button">
{value.length === 0 ? (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Plus className="size-3.5" /> Add tags
</span>
) : (
<>
{value.slice(0, 2).map((tag) => (
<Badge className="gap-1 rounded-full" key={tag.id} variant="secondary">
<span className="size-1.5 rounded-full" style={{ backgroundColor: tag.color }} />
{tag.name}
</Badge>
))}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[260px] p-0">
<Command>
<CommandInput placeholder="Add tags..." />
<CommandList>
<CommandEmpty>No tags found.</CommandEmpty>
{options.map((tag) => {
const selected = selectedIds.has(tag.id)
return (
<CommandItem
key={tag.id}
onSelect={() => {
const next = selected ? value.filter((item) => item.id !== tag.id) : [...value, tag]
onChange(next)
}}
value={tag.name}
>
<span className="inline-flex size-4 items-center justify-center rounded-sm border border-border">
{selected ? <Check className="size-3" /> : null}
</span>
<span className="size-2 rounded-full" style={{ backgroundColor: tag.color }} />
<span>{tag.name}</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}Wire it into a column definition
The important detail is the accessor: the column still exposes a stable string representation for table semantics even though the UI renders badges from richer objects.
Even with a custom cell, the column still needs an accessor. In this example that is
accessorFn={(row) => row.tags.map((tag) => tag.name).join(", ")}. That gives the table a stable field for sorting, filtering, and saved state, while the cell can still read extra data from row.original.{
id: "tags",
header: "Tags",
accessorFn: (row) => row.tags.map((tag) => tag.name).join(", "),
width: 260,
enableSorting: false,
enableFiltering: false,
cell: ({ row }) => (
<MultiSelectCell
value={row.original.tags}
options={tagOptions}
onChange={(tags) => updateRow(row.original.id, { tags })}
/>
),
}Render it in a small table
The table example keeps only the tags column custom so you can see how the badge picker sits inside a normal Datatable.
function TagsExampleTable() {
const [rows, setRows] = useState(seedRows)
const updateRow = (id: string, patch: Partial<CustomerRow>) => {
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)))
}
return <Datatable tableKey="custom-cells" data={rows} columns={columns(updateRow)} getRowId={(row) => row.id} toolbar={false} />
}Mini table demo
Loading table preferences...
Persist changes with optimistic update
For server sync, treat the selected tags as a small optimistic patch and then refetch the authoritative row.
async function updateTags(id: string, tags: Tag[]) {
setRows((current) => current.map((row) => (row.id === id ? { ...row, tags } : row)))
try {
await api.customers.updateTags({ id, tagIds: tags.map((tag) => tag.id) })
await refetchCustomers()
} catch {
await refetchCustomers()
}
}