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 />
</>
);
}
Compose your own
These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.
Scroll horizontally for long lines
"use client";
import {
SearchableRecordsScreen,
SearchableRecordsScreenToolbar,
SearchableRecordsScreenRecords,
} from "@/components/jez-ui/blocks/searchable-records-screen";
export default function Example() {
return (
<SearchableRecordsScreen>
<SearchableRecordsScreenToolbar />
<SearchableRecordsScreenRecords />
</SearchableRecordsScreen>
);
}
Scroll horizontally for long lines
import { SearchableRecordsScreen, SearchableRecordsScreenContent, SearchableRecordsScreenTitle, SearchableRecordsScreenToolbar, SearchableRecordsScreenRecords } from "@/components/jez-ui/blocks/searchable-records-screen";Props & types
Scroll horizontally for long lines
export type SearchableRecordsScreenOptions = {
className?: string;
records?: typeof defaultDemoRecords;
heading?: React.ReactNode;
};
export type SearchableRecordsScreenProps = Omit<
React.ComponentProps<"section">,
keyof SearchableRecordsScreenOptions
> &
SearchableRecordsScreenOptions;
SearchableRecordsScreenPropsSource
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 defaultDemoRecords = [
{
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 type SearchableRecordsScreenOptions = {
className?: string;
records?: typeof defaultDemoRecords;
heading?: React.ReactNode;
};
export type SearchableRecordsScreenProps = Omit<
React.ComponentProps<"section">,
keyof SearchableRecordsScreenOptions
> &
SearchableRecordsScreenOptions;
export const demoRecords = defaultDemoRecords;
function useSearchableRecordsScreenModel({
records = defaultDemoRecords,
heading = "Customer directory",
className,
children,
...rootProps
}: SearchableRecordsScreenProps) {
return { records, heading, className, children, rootProps };
}
const SearchableRecordsScreenCompositionContext =
React.createContext<ReturnType<
typeof useSearchableRecordsScreenModel
> | null>(null);
function useSearchableRecordsScreenComposition() {
const context = React.useContext(SearchableRecordsScreenCompositionContext);
if (!context)
throw new Error(
"SearchableRecordsScreen parts must be inside SearchableRecordsScreen.",
);
return context;
}
export function SearchableRecordsScreen(props: SearchableRecordsScreenProps) {
const model = useSearchableRecordsScreenModel(props);
const { className, rootProps, children } = model;
return (
<SearchableRecordsScreenCompositionContext.Provider value={model}>
<section {...rootProps} className={cn("", className)}>
{children !== undefined ? (
children
) : (
<>
<SearchableRecordsScreenToolbar />
<SearchableRecordsScreenRecords />
</>
)}
</section>
</SearchableRecordsScreenCompositionContext.Provider>
);
}
export function SearchableRecordsScreenContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="searchable-records-screen-content"
className={cn("mb-6", className)}
{...props}
/>
);
}
export function SearchableRecordsScreenTitle({
className,
...props
}: React.ComponentProps<"h2">) {
return (
<h2
data-slot="searchable-records-screen-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
export function SearchableRecordsScreenToolbar({
children,
...props
}: Partial<React.ComponentProps<typeof SearchableRecordsScreenContent>> & {
children?: React.ReactNode;
}) {
const { heading } = useSearchableRecordsScreenComposition();
return (
<SearchableRecordsScreenContent {...props}>
{children === undefined ? (
<>
<SearchableRecordsScreenTitle>{heading}</SearchableRecordsScreenTitle>
<p className="mt-1 text-sm text-muted-foreground">
People and businesses using your product.
</p>
</>
) : (
children
)}
</SearchableRecordsScreenContent>
);
}
export function SearchableRecordsScreenRecords({
children,
...props
}: Partial<React.ComponentProps<typeof DataTable>>) {
const { records } = useSearchableRecordsScreenComposition();
return (
<DataTable
data={records}
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(),
},
]}
{...props}
>
{children}
</DataTable>
);
}
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.ComponentProps<"span"> & {
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,
type TableOptions,
type Table as TableInstance,
} 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 type DataTableOptions<T> = {
data: T[];
columns: ColumnDef<T, any>[];
label?: string;
className?: string;
selectable?: boolean;
options?: Partial<
Omit<TableOptions<T>, "data" | "columns" | "getCoreRowModel">
>;
toolbar?: React.ReactNode | ((table: TableInstance<T>) => React.ReactNode);
footer?: React.ReactNode | ((table: TableInstance<T>) => React.ReactNode);
emptyState?: React.ReactNode;
loading?: boolean;
onSelectionChange?: (rows: T[]) => void;
children?: React.ReactNode | ((table: TableInstance<T>) => React.ReactNode);
};
export type DataTableProps<T> = Omit<
React.ComponentProps<"div">,
keyof DataTableOptions<T>
> &
DataTableOptions<T>;
export function DataTable<T>({
children,
data,
columns,
label = "Records",
className,
selectable = false,
onSelectionChange,
options,
toolbar,
footer,
emptyState = "No matching records. Try another search.",
loading = false,
...rootProps
}: DataTableProps<T>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [filter, setFilter] = React.useState("");
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const table = useReactTable({
data,
columns,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
enableRowSelection: selectable,
onSortingChange: setSorting,
onGlobalFilterChange: setFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
...options,
state: {
sorting,
globalFilter: filter,
columnVisibility,
rowSelection,
...options?.state,
},
initialState: { pagination: { pageSize: 6 }, ...options?.initialState },
});
const selection = table.getState().rowSelection;
const selectionCallback = React.useRef(onSelectionChange);
selectionCallback.current = onSelectionChange;
React.useEffect(() => {
selectionCallback.current?.(
table.getSelectedRowModel().rows.map((row) => row.original),
);
}, [table, selection, data]);
return (
<div
{...rootProps}
aria-busy={loading || undefined}
className={cn("grid gap-4", className)}
>
{children !== undefined ? (
typeof children === "function" ? (
children(table)
) : (
children
)
) : (
<>
{toolbar !== undefined ? (
typeof toolbar === "function" ? (
toolbar(table)
) : (
toolbar
)
) : (
<DataTableToolbar>
<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>
</DataTableToolbar>
)}
<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"
>
{loading ? "Loading records…" : emptyState}
</td>
</tr>
)}
</tbody>
</table>
</div>
{footer !== undefined ? (
typeof footer === "function" ? (
footer(table)
) : (
footer
)
) : (
<DataTablePagination>
<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>
</DataTablePagination>
)}
</>
)}
</div>
);
}
export function Table({
className,
containerProps,
...props
}: React.ComponentProps<"table"> & {
containerProps?: React.ComponentProps<"div">;
}) {
return (
<div
{...containerProps}
className={cn(
"w-full overflow-x-auto rounded-lg border border-border",
containerProps?.className,
)}
>
<table className={cn("w-full text-left text-sm", className)} {...props} />
</div>
);
}
export function TableHeader({
className,
...props
}: React.ComponentProps<"thead">) {
return (
<thead
className={cn("border-b border-border bg-muted/40", className)}
{...props}
/>
);
}
export function TableBody({
className,
...props
}: React.ComponentProps<"tbody">) {
return (
<tbody className={cn("divide-y divide-border", className)} {...props} />
);
}
export function TableFooter({
className,
...props
}: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn(
"border-t border-border bg-muted/40 font-medium",
className,
)}
{...props}
/>
);
}
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"transition-colors hover:bg-muted/40 data-[state=selected]:bg-primary/5",
className,
)}
{...props}
/>
);
}
export function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
scope="col"
className={cn(
"h-11 px-4 text-xs font-medium text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return <td className={cn("px-4 py-3 align-middle", className)} {...props} />;
}
export function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
className={cn(
"caption-bottom px-4 py-3 text-left text-xs text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function DataTableToolbar({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex items-center justify-between gap-3", className)}
{...props}
/>
);
}
export function DataTablePagination({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex items-center justify-between gap-3 text-sm",
className,
)}
{...props}
/>
);
}
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,
inputClassName,
ref: forwardedRef,
clearLabel = "Clear search",
loading = false,
...props
}: Omit<
React.ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange"
> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
loading?: boolean;
inputClassName?: string;
clearLabel?: string;
}) {
const [query, setQuery] = useControllable(value, defaultValue, onValueChange);
const ref = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(forwardedRef, () => ref.current!, []);
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={cn(
"px-10 [&::-webkit-search-cancel-button]:appearance-none",
inputClassName,
)}
/>
{query && (
<button
type="button"
disabled={props.disabled}
aria-label={clearLabel}
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 { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
value: T | undefined,
initial: T,
onChange?: (value: T) => void,
) {
const [local, setLocal] = useState(initial);
const current = useRef(value === undefined ? local : value);
current.current = value === undefined ? local : value;
return [
current.current,
(action: SetStateAction<T>) => {
const next =
typeof action === "function"
? (action as (previous: T) => T)(current.current)
: action;
current.current = next;
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 { Popover as P } from "radix-ui";
import { cn } from "./utils";
export function Popover({
trigger,
children,
className,
...props
}: React.ComponentProps<typeof P.Root> & {
trigger?: React.ReactNode;
className?: string;
}) {
return (
<P.Root {...props}>
{trigger !== undefined ? (
<>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className={className}>{children}</PopoverContent>
</>
) : (
children
)}
</P.Root>
);
}
export const PopoverTrigger = P.Trigger;
export const PopoverAnchor = P.Anchor;
export const PopoverClose = P.Close;
export function PopoverContent({
className,
...props
}: React.ComponentProps<typeof P.Content>) {
return (
<P.Portal>
<P.Content
sideOffset={8}
align="start"
collisionPadding={16}
className={cn(
"jez-popover z-50 w-72 max-w-[calc(100vw-32px)] rounded-xl border border-border bg-background p-5 text-sm leading-relaxed text-foreground shadow-lg",
className,
)}
{...props}
/>
</P.Portal>
);
}
export function PopoverHeader({
className,
...props
}: React.ComponentProps<"div">) {
return <div className={cn("mb-4 grid gap-1.5", className)} {...props} />;
}
export function PopoverTitle({
className,
...props
}: React.ComponentProps<"h3">) {
return (
<h3 className={cn("font-medium leading-tight", className)} {...props} />
);
}
export function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}
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.ComponentProps<"button"> & {
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 →