ListGroup
Data DisplaySemantic lists for resource links, single-choice actions, and static information.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { ListGroup } from "@/components/ui/list-group";
import { Bell, CreditCard, ShieldCheck } from "lucide-react";
import { useState } from "react";
export default function ListGroupExample() {
const [selected, setSelected] = useState("notifications");
return (
<div className="grid w-full max-w-2xl gap-7 sm:grid-cols-2">
<div className="space-y-3">
<h3 className="text-sm font-semibold">Workspace preferences</h3>
<ListGroup
label="Workspace preferences"
selectedId={selected}
onSelect={setSelected}
items={[
{
id: "notifications",
label: "Notifications",
description: "Choose how we keep you updated",
leading: <Bell className="size-4" />,
},
{
id: "security",
label: "Security",
description: "Manage access and sessions",
leading: <ShieldCheck className="size-4" />,
},
{
id: "billing",
label: "Billing",
description: "Owner access required",
disabled: true,
leading: <CreditCard className="size-4" />,
},
]}
/>
<p role="status" className="text-xs text-muted-foreground">
Selected: {selected}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">Resource links</h3>
<ListGroup
label="Resource links"
variant="plain"
items={[
{
id: "getting-started",
label: "Getting started",
description: "Set up your workspace",
href: "#getting-started",
},
{
id: "support",
label: "Contact support",
description: "We are here to help",
href: "mailto:support@example.com",
},
]}
/>
</div>
</div>
);
}Usage
Provide unique ids and descriptive labels. Items with href render links; items without href render action buttons when onSelect is supplied, or static content otherwise. The component does not mix link navigation with selection callbacks. Own selectedId when using choices, and render only noninteractive content in leading/trailing slots to avoid nested controls.
import { ListGroup } from "@/components/ui/list-group";Shipped as editable source in src/components/ui/list-group.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/list-group.tsx
import type { ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ListGroupItem {
id: string;
label: string;
description?: string;
leading?: ReactNode;
trailing?: ReactNode;
href?: string;
disabled?: boolean;
}
export interface ListGroupProps {
label: string;
items: ListGroupItem[];
selectedId?: string;
onSelect?: (id: string) => void;
variant?: "bordered" | "plain";
className?: string;
}
export function ListGroup({
label,
items,
selectedId,
onSelect,
variant = "bordered",
className,
}: ListGroupProps) {
return (
<ul
aria-label={label}
className={cn(
"min-w-0 divide-y overflow-hidden",
variant === "bordered" && "rounded-xl border",
className,
)}
>
{items.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
No items available.
</li>
)}
{items.map((item) => {
const content = (
<>
{item.leading && (
<span aria-hidden className="shrink-0">
{item.leading}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{item.label}</span>
{item.description && (
<span className="mt-1 block text-xs text-muted-foreground">
{item.description}
</span>
)}
</span>
{item.trailing}
{item.href && (
<ChevronRight
aria-hidden
className="size-4 shrink-0 rtl:rotate-180"
/>
)}
</>
);
const style = cn(
"flex w-full items-center gap-3 p-4 text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
item.disabled
? "opacity-50"
: (item.href || onSelect) && "hover:bg-muted/50",
selectedId === item.id && "bg-primary/5",
);
return (
<li key={item.id}>
{item.href && !item.disabled ? (
<a
className={style}
href={item.href}
aria-current={selectedId === item.id ? "page" : undefined}
>
{content}
</a>
) : onSelect && !item.href ? (
<button
type="button"
className={style}
disabled={item.disabled}
aria-pressed={selectedId === item.id}
onClick={() => onSelect(item.id)}
>
{content}
</button>
) : (
<div className={style} aria-disabled={item.disabled || undefined}>
{content}
</div>
)}
</li>
);
})}
</ul>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible name for the list. |
| items | ListGroupItem[]Default: required | Unique id, label, optional description, leading/trailing content, href, and disabled. |
| selectedId | stringDefault: — | Controlled selected action or current link id. |
| onSelect | (id: string) => voidDefault: — | Enable action buttons for items without href. |
| variant | bordered | plainDefault: bordered | Outer border treatment. |
| className | stringDefault: — | Extend the component container with theme-aware utility classes. |