Searchable Records Screen
A working searchable records screen with illustrative data and frontend interactions.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/searchable-records-screen.jsonUse it
Scroll horizontally for long lines
"use client";
import {SearchableRecordsScreen} from '@/components/jez-ui/blocks/searchable-records-screen';
export default function Example(){
return <><SearchableRecordsScreen/></>;
}Props & types
Scroll horizontally for long lines
{ className?: string }Source
View 10 source files
registry/blocks/searchable-records-screen.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "../ui/utils";
import { Badge } from "../ui/badge";
import { DataTable } from "../ui/data-table";
export const demoRecords = [
{
name: "Alex Morgan",
email: "alex@example.com",
status: "Active",
revenue: 240,
},
{
name: "Sam Patel",
email: "sam@example.com",
status: "Active",
revenue: 180,
},
{
name: "Robin Lee",
email: "robin@example.com",
status: "Invited",
revenue: 0,
},
{
name: "Casey Bell",
email: "casey@example.com",
status: "Active",
revenue: 360,
},
{
name: "Jamie Chen",
email: "jamie@example.com",
status: "Paused",
revenue: 90,
},
{
name: "Taylor Green",
email: "taylor@example.com",
status: "Active",
revenue: 120,
},
{
name: "Drew Ellis",
email: "drew@example.com",
status: "Invited",
revenue: 0,
},
];
export function SearchableRecordsScreen({ className }: { className?: string }) {
return (
<section className={cn("", className)}>
<div className="mb-6">
<h2 className="text-lg font-semibold">Customer directory</h2>
<p className="mt-1 text-sm text-muted-foreground">
People and businesses using your product.
</p>
</div>
<DataTable
data={demoRecords}
columns={[
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{
accessorKey: "status",
header: "Status",
cell: (c) => (
<Badge tone={c.getValue() === "Active" ? "positive" : "neutral"}>
{String(c.getValue())}
</Badge>
),
},
{
accessorKey: "revenue",
header: "Revenue",
cell: (c) => "£" + c.getValue(),
},
]}
/>
</section>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
registry/ui/badge.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Badge({
className,
tone = "neutral",
...props
}: React.HTMLAttributes<HTMLSpanElement> & {
tone?: "neutral" | "accent" | "positive" | "warning";
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
{
"bg-muted text-foreground": tone === "neutral",
"bg-primary/10 text-primary": tone === "accent",
"bg-accent text-[#293620]": tone === "positive",
"bg-[#f6e5c1] text-[#704517]": tone === "warning",
},
className,
)}
{...props}
/>
);
}
registry/ui/data-table.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type VisibilityState,
type RowSelectionState,
} from "@tanstack/react-table";
import { SearchInput } from "./search-input";
import {
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Columns3 } from "lucide-react";
import { Checkbox } from "./checkbox";
import { Popover } from "./popover";
import { Button } from "./button";
export function DataTable<T>({
data,
columns,
label = "Records",
className,
selectable = false,
onSelectionChange,
}: {
data: T[];
columns: ColumnDef<T, any>[];
label?: string;
className?: string;
selectable?: boolean;
onSelectionChange?: (rows: T[]) => void;
}) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [filter, setFilter] = React.useState("");
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
React.useEffect(() => {
onSelectionChange?.(data.filter((_, i) => rowSelection[String(i)]));
}, [data, rowSelection, onSelectionChange]);
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter: filter, columnVisibility, rowSelection },
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
enableRowSelection: selectable,
onSortingChange: setSorting,
onGlobalFilterChange: setFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 6 } },
});
return (
<div className={cn("grid gap-4", className)}>
<div className="flex items-center justify-between gap-3">
<SearchInput
aria-label={`Search ${label}`}
placeholder={`Search ${label.toLowerCase()}…`}
value={filter}
onValueChange={setFilter}
className="max-w-xs"
/>
<Popover
trigger={
<Button
variant="outline"
size="sm"
aria-label="Choose visible columns"
>
<Columns3 size={14} />
<span className="hidden sm:inline">Columns</span>
</Button>
}
>
<p className="mb-3 text-xs font-medium text-muted-foreground">
Visible columns
</p>
<div className="grid gap-3">
{table.getAllLeafColumns().map((column) => (
<label
key={column.id}
className="flex items-center gap-3 text-sm"
>
<Checkbox
checked={column.getIsVisible()}
onCheckedChange={(v) => column.toggleVisibility(!!v)}
disabled={
column.getIsVisible() &&
table.getVisibleLeafColumns().length === 1
}
/>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id}
</label>
))}
</div>
</Popover>
</div>
<p className="text-xs text-muted-foreground sm:hidden">
Scroll horizontally to see all columns.
</p>
<div
tabIndex={0}
aria-label={`${label} table, scroll horizontally for more columns`}
className="overflow-x-auto border-y border-border"
>
<table className="w-full text-left text-sm">
<caption className="sr-only">{label}</caption>
<thead className="bg-muted/30 text-xs text-muted-foreground">
{table.getHeaderGroups().map((g) => (
<tr key={g.id}>
{selectable && (
<th className="w-10 pl-4">
<Checkbox
aria-label="Select all rows on this page"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected()
? "indeterminate"
: false)
}
onCheckedChange={(v) =>
table.toggleAllPageRowsSelected(!!v)
}
/>
</th>
)}
{g.headers.map((h) => (
<th
key={h.id}
aria-sort={
h.column.getIsSorted() === "asc"
? "ascending"
: h.column.getIsSorted() === "desc"
? "descending"
: undefined
}
className="whitespace-nowrap px-4 py-3 font-medium"
>
{h.column.getCanSort() ? (
<button
className="flex items-center gap-2 transition-colors hover:text-foreground"
onClick={h.column.getToggleSortingHandler()}
>
{flexRender(h.column.columnDef.header, h.getContext())}
<span aria-hidden="true">
{h.column.getIsSorted() === "asc" ? (
<ArrowUp size={13} />
) : h.column.getIsSorted() === "desc" ? (
<ArrowDown size={13} />
) : (
<ArrowUpDown size={13} className="opacity-40" />
)}
</span>
</button>
) : (
flexRender(h.column.columnDef.header, h.getContext())
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (
<tr
key={r.id}
className={cn(
"border-t border-border/60 transition-colors hover:bg-muted/40",
r.getIsSelected() && "bg-primary/4",
)}
>
{selectable && (
<td className="w-10 pl-4">
<Checkbox
aria-label={`Select row ${r.index + 1}`}
checked={r.getIsSelected()}
onCheckedChange={(v) => r.toggleSelected(!!v)}
/>
</td>
)}
{r.getVisibleCells().map((c) => (
<td
key={c.id}
className="whitespace-nowrap px-4 py-4 first:font-medium"
>
{flexRender(c.column.columnDef.cell, c.getContext())}
</td>
))}
</tr>
))}
{!table.getRowModel().rows.length && (
<tr>
<td
colSpan={
table.getVisibleLeafColumns().length + (selectable ? 1 : 0)
}
className="p-6 text-center text-muted-foreground"
>
No matching records. Try another search.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-xs text-muted-foreground">
{Object.keys(rowSelection).length > 0
? `${Object.keys(rowSelection).length} selected · `
: ""}
{table.getFilteredRowModel().rows.length} records · Page{" "}
{table.getState().pagination.pageIndex + 1} of{" "}
{Math.max(1, table.getPageCount())}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
<ChevronLeft size={14} /> Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next <ChevronRight size={14} />
</Button>
</div>
</div>
</div>
);
}
registry/ui/search-input.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Search, X, LoaderCircle } from "lucide-react";
import { Input } from "./input";
import { useControllable } from "./use-controllable";
import { cn } from "./utils";
export function SearchInput({
value,
defaultValue = "",
onValueChange,
className,
loading = false,
...props
}: Omit<
React.ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange"
> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
loading?: boolean;
}) {
const [query, setQuery] = useControllable(value, defaultValue, onValueChange);
const ref = React.useRef<HTMLInputElement>(null);
return (
<div role="search" className={cn("group relative w-full", className)}>
{loading ? (
<LoaderCircle
size={16}
className="absolute left-3 top-3 animate-spin text-primary"
/>
) : (
<Search
size={16}
className="pointer-events-none absolute left-3 top-3 text-muted-foreground transition-colors group-focus-within:text-primary"
/>
)}
<Input
ref={ref}
type="search"
aria-label="Search"
placeholder="Search…"
{...props}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="px-10 [&::-webkit-search-cancel-button]:appearance-none"
/>
{query && (
<button
type="button"
disabled={props.disabled}
aria-label="Clear search"
onClick={() => {
setQuery("");
ref.current?.focus();
}}
className="absolute right-1 top-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={14} />
</button>
)}
</div>
);
}
registry/ui/input.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Input({ className, ...props }: React.ComponentProps<"input">) {
return (
<input
className={cn(
"block h-10 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-sm leading-normal shadow-[0_1px_2px_#00000004] transition-[border-color,box-shadow] placeholder:text-muted-foreground/75 hover:border-foreground/25 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/10 aria-invalid:border-danger aria-invalid:ring-danger/10 disabled:cursor-not-allowed disabled:bg-muted/50 disabled:opacity-50",
className,
)}
{...props}
/>
);
}
registry/ui/use-controllable.ts
Scroll horizontally for long lines
"use client";
import { useState } from "react";
export function useControllable<T>(
value: T | undefined,
initial: T,
onChange?: (value: T) => void,
) {
const [local, setLocal] = useState(initial);
return [
value === undefined ? local : value,
(next: T) => {
if (value === undefined) setLocal(next);
onChange?.(next);
},
] as const;
}
registry/ui/checkbox.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
import { Checkbox as Primitive } from "radix-ui";
export function Checkbox({
className,
...props
}: React.ComponentProps<typeof Primitive.Root>) {
return (
<Primitive.Root
className={cn(
"size-5 rounded border border-border data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<Primitive.Indicator className="flex items-center justify-center">
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path
d="m3 8 3 3 7-7"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Primitive.Indicator>
</Primitive.Root>
);
}
registry/ui/popover.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
import { Popover as Primitive } from "radix-ui";
export function Popover({
trigger,
children,
className,
...props
}: React.ComponentProps<typeof Primitive.Root> & {
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<Primitive.Root {...props}>
<Primitive.Trigger asChild>{trigger}</Primitive.Trigger>
<Primitive.Portal>
<Primitive.Content
sideOffset={8}
align="start"
collisionPadding={16}
className={cn(
"jez-popover z-50 max-w-[calc(100vw-32px)] w-72 rounded-xl border border-border bg-background p-5 text-foreground shadow-lg",
className,
)}
>
{children}
<Primitive.Arrow className="fill-background" />
</Primitive.Content>
</Primitive.Portal>
</Primitive.Root>
);
}
registry/ui/button.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { LoaderCircle } from "lucide-react";
import { cn } from "./utils";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
loading?: boolean;
asChild?: boolean;
};
export function Button({
variant = "primary",
size = "md",
loading,
asChild,
className,
children,
disabled,
...props
}: ButtonProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
type={asChild ? undefined : "button"}
disabled={asChild ? undefined : disabled || loading}
aria-disabled={disabled || loading || undefined}
aria-busy={loading || undefined}
className={cn(
"relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium leading-none transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[.98] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none [&_svg]:shrink-0",
{
"bg-primary text-primary-foreground shadow-[inset_0_1px_0_#ffffff26,0_1px_2px_#00000012] hover:bg-primary/90":
variant === "primary",
"border-border/60 bg-muted text-foreground hover:bg-muted/70":
variant === "secondary",
"border-border bg-background text-foreground shadow-sm hover:bg-muted/60":
variant === "outline",
"text-muted-foreground hover:bg-muted hover:text-foreground":
variant === "ghost",
"bg-danger text-danger-foreground hover:bg-danger/90":
variant === "danger",
},
{
"h-8 px-3 text-xs": size === "sm",
"h-10 px-4": size === "md",
"h-12 px-6": size === "lg",
},
className,
)}
{...props}
>
{asChild ? (
children
) : (
<>
<span
className={cn(
"inline-flex min-w-0 flex-1 items-center justify-center gap-2",
loading && "invisible",
)}
>
{children}
</span>
{loading && (
<LoaderCircle
aria-hidden="true"
className="absolute size-4 animate-spin motion-reduce:animate-none"
/>
)}
</>
)}
</Comp>
);
}
Dependencies
clsx@2.1.1 · tailwind-merge@3.5.0 · @tanstack/react-table@8.21.3 · lucide-react@0.577.0 · radix-ui@1.6.7
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →