Avatar select cell
This example builds an owner picker with avatars, search, and an unassigned state.
Live preview
Build the cell component
The main point here is simple: custom cells are just React code. Start by building the cell UI the same way you would build any other React component.
import { Check, ChevronsUpDown } from "lucide-react"
import { useState } from "react"
import { SearchableDropdown } from "@/components/searchable-dropdown"
type Owner = {
id: string
name: string
initials: string
color: string
}
export function AvatarSelectCell({
value,
owners,
onChange,
}: {
value: Owner | null
owners: Owner[]
onChange: (owner: Owner | null) => void
}) {
const [open, setOpen] = useState(false)
const items = [
{ id: "unassigned", name: "Unassigned", initials: "\u2014", color: "#94a3b8" } as Owner,
...owners,
]
return (
<SearchableDropdown
open={open}
onOpenChange={setOpen}
items={items}
getItemKey={(item) => item.id}
filterFn={(item, search) => item.name.toLowerCase().includes(search.trim().toLowerCase())}
onSelect={(item) => onChange(item.id === "unassigned" ? null : item)}
renderItem={(item, selected) => (
<span className="flex items-center gap-1.5 text-[12px] leading-4">
<span
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: item.color }}
>
{item.initials}
</span>
<span className="flex-1 truncate">{item.name}</span>
{selected ? <Check aria-hidden="true" className="ml-auto size-4" /> : null}
</span>
)}
searchPlaceholder="Assign owner..."
width="w-[240px]"
>
<button className="flex h-7 w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-[12px] leading-4 hover:bg-accent" type="button">
<span
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: value?.color ?? "#cbd5e1" }}
>
{value?.initials ?? "\u2014"}
</span>
<span className="min-w-0 flex-1 truncate">{value?.name ?? "Unassigned"}</span>
<ChevronsUpDown aria-hidden="true" className="size-3.5 text-muted-foreground" />
</button>
</SearchableDropdown>
)
}This component renders the current owner, opens the picker, and calls onChange when someone picks a new value.
SearchableDropdown helper
import { useEffect, useRef } from "react"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export function SearchableDropdown<T>({
open,
onOpenChange,
items,
renderItem,
getItemKey,
onSelect,
searchPlaceholder = "Search...",
filterFn,
width = "w-56",
children,
}: SearchableDropdownProps<T>) {
const searchInputRef = useRef<HTMLInputElement>(null)
const { search, setSearch, selectedIndex, handleKeyDown, filteredItems } = useDropdownKeyboardNav({
items,
onSelect: (item) => {
onSelect(item)
onOpenChange(false)
},
filterFn,
onEscape: () => onOpenChange(false),
})
useEffect(() => {
if (!open) {
setSearch("")
}
}, [open, setSearch])
return (
<Popover open={open} onOpenChange={onOpenChange}>
{children && <PopoverTrigger asChild>{children}</PopoverTrigger>}
<PopoverContent className={width + " z-[100] overflow-hidden p-0"} align="start">
<div className="flex flex-col" onKeyDown={handleKeyDown}>
<div className="bg-popover sticky top-0 z-10 border-b p-0.5">
<Input
ref={searchInputRef}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
className="inline-input h-8 border-none bg-transparent px-2 py-1"
/>
</div>
<div className="scrollbar-hidden overflow-y-auto py-1">
{filteredItems.map((item, index) => (
<button
key={getItemKey(item)}
type="button"
onClick={() => {
onSelect(item)
onOpenChange(false)
}}
className={index === selectedIndex ? "bg-accent text-accent-foreground mx-1 rounded px-2 py-1.5 text-left" : "mx-1 rounded px-2 py-1.5 text-left hover:bg-accent"}
>
{renderItem(item, index === selectedIndex)}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)
}The avatar cell uses a reusable dropdown helper. You do not need this exact helper in your own app, but it is worth showing here because it contains the search, popover, and keyboard behavior that make the picker work.
Connect it to a column
Next, wire the component into the column definition. The cell handles the UI. The column tells the table which field it is rendering.
accessorFn={(row) => row.owner?.name ?? "Unassigned"}. That gives the table a stable field for sorting, filtering, and saved state, while the cell can still read extra data from row.original.const columns: DatatableColumn<CustomerRow>[] = [
{
id: "owner",
header: "Owner",
accessorFn: (row) => row.owner?.name ?? "Unassigned",
width: 220,
enableSorting: false,
filterType: "text-list",
filterOptions: {
options: owners.map((owner) => ({ value: owner.name, label: owner.name })),
renderOption: (option) => {
const owner = owners.find((item) => item.name === option.value)
return (
<>
{owner ? (
<span
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: owner.color }}
>
{owner.initials}
</span>
) : null}
<span className="truncate">{option.label}</span>
</>
)
},
},
cell: ({ row }) => (
<AvatarSelectCell
value={row.original.owner}
owners={owners}
onChange={(owner) => handleOwnerChange(row.original.id, owner)}
/>
),
},
]The cell renderer and the filter option renderer are separate extension points. The cell controls how the value appears inside the grid; filterOptions.renderOption controls how each owner appears in the built-in text-list filter menu.
Full table example
This is the full pattern in one place: the table owns the row state, the custom cell emits the new owner, and the table owns the optimistic update and refetch.
The column order here is deliberate: keep the interactive owner picker near the front so people can actually see and use it without scrolling past low-value fields first.
In the example, useCustomersPage(queryState) stands in for your page-level data hook. That hook gives the table both the current rows and refetchPage(). The cell stays focused on rendering and interaction only.
function CustomersTable() {
// Your page-level data hook owns the current query and exposes a way to refresh it.
const { rows: serverRows, refetchPage } = useCustomersPage(queryState)
const [rows, setRows] = useState(serverRows)
useEffect(() => {
setRows(serverRows)
}, [serverRows])
const handleOwnerChange = async (id: string, owner: Owner | null) => {
// Optimistic update: show the new owner in the table immediately.
setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row)))
try {
await api.customers.updateOwner({ id, ownerId: owner?.id ?? null })
await refetchPage() // Re-sync the current query with server truth after the save.
} catch {
await refetchPage() // Restore server truth if the mutation failed.
}
}
const columns = [
{ id: "name", header: "Contact", accessorKey: "name", width: 180 },
{
id: "owner",
header: "Owner",
accessorFn: (row) => row.owner?.name ?? "Unassigned",
width: 220,
filterType: "text-list",
filterOptions: {
options: owners.map((owner) => ({ value: owner.name, label: owner.name })),
renderOption: (option) => {
const owner = owners.find((item) => item.name === option.value)
return (
<>
{owner ? (
<span
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: owner.color }}
>
{owner.initials}
</span>
) : null}
<span className="truncate">{option.label}</span>
</>
)
},
},
cell: ({ row }) => (
<AvatarSelectCell
value={row.original.owner}
owners={owners}
onChange={(owner) => handleOwnerChange(row.original.id, owner)}
/>
),
},
{ id: "company", header: "Company", accessorKey: "company", width: 190 },
{ id: "status", header: "Status", accessorKey: "status", width: 140 },
{ id: "health", header: "Health", accessorKey: "health", width: 140 },
{ id: "renewal", header: "Renewal", accessorKey: "renewal", width: 150 },
] satisfies DatatableColumn<CustomerRow>[]
return <Datatable tableKey="custom-cells" data={rows} columns={columns} getRowId={(row) => row.id} toolbar={false} />
}Full table example
Update flow
This section matters because the cell UI is only half the job. For a real table, readers also need to know where the save logic lives and why the value appears to change immediately.
This example uses the normal production pattern: an optimistic update.
- the cell emits the new owner
- the table updates local row state immediately
- the table sends the mutation in the background
- on success, the table refetches the current query to sync with server truth
- on failure, the same refetch restores the correct server state
const handleOwnerChange = async (id: string, owner: Owner | null) => {
// Optimistic update: update local row state before the request finishes.
setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row)))
try {
await api.customers.updateOwner({ id, ownerId: owner?.id ?? null })
await refetchPage() // Re-run the current table query to sync with server truth.
} catch {
await refetchPage() // Roll back to server truth if the save failed.
}
}If your table is local-only, you may not need a refetch at all. If it is backed by server data, keep the refetch in the table or page layer so the cell never has to know how persistence works.