HBO · 2004
HBO · 2002
ABC · 1990
Fox · 1989
AMC · 2007
Channel 4 · 2003
HBO · 1999
<ItemCarousel id="item-carousel-recommendations" items={[ { "id": "rec-1", "name": "Deadwood", "subtitle": "HBO · 2004", "color": "#8b6f47", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] }, { "id": "rec-2", "name": "The Wire", "subtitle": "HBO · 2002", "color": "#1e293b", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] }, { "id": "rec-3", "name": "Twin Peaks", "subtitle": "ABC · 1990", "color": "#7f1d1d", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] }, { "id": "rec-4", "name": "The Simpsons", "subtitle": "Fox · 1989", "color": "#fbbf24", "actions": [ { "id": "add", "label": "Add to List" } ] }, { "id": "rec-5", "name": "Mad Men", "subtitle": "AMC · 2007", "color": "#c2410c", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] }, { "id": "rec-6", "name": "Peep Show", "subtitle": "Channel 4 · 2003", "color": "#1e40af", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] }, { "id": "rec-7", "name": "The Sopranos", "subtitle": "HBO · 1999", "color": "#991b1b", "actions": [ { "id": "info", "label": "Details", "variant": "secondary" }, { "id": "watch", "label": "Watch" } ] } ]} onItemClick={(itemId) => console.log("Clicked:", itemId)} onItemAction={(itemId, actionId) => console.log("Action:", itemId, actionId)}/>A vertical list of ten search results buries everything below the fold. ItemCarousel lays them out in a scrollable row of cards so the user can browse products, restaurants, or any collection the assistant returns without losing their place in the conversation. Each card shows an image, subtitle, and action buttons.
Role: Information. For displaying data without user input. See Design Guidelines for how component roles work.
Run this once from your project root.
npx shadcn@latest add @tool-ui/item-carouselRender ItemCarousel in your UI with tool-compatible props.
import { ItemCarousel } from "@/components/tool-ui/item-carousel";export function Example({ payload }: { payload: React.ComponentProps<typeof ItemCarousel> }) { return <ItemCarousel {...payload} />;}Registration tells assistant-ui which component to render when a tool named searchItems returns data. Without it, tool results appear as raw JSON.
// Backend toolimport { tool } from "ai";import { z } from "zod";import { SerializableItemCarouselSchema } from "@/components/tool-ui/item-carousel/schema";const searchItems = tool({ description: "Search for items matching criteria", inputSchema: z.object({ query: z.string(), maxPrice: z.number().optional(), }), outputSchema: SerializableItemCarouselSchema, async execute({ query, maxPrice }) { const items = await fetchItems(query, maxPrice); return { id: `item-search-${query}`, items: items.map((item) => ({ id: item.id, name: item.name, subtitle: `$${item.price.toFixed(2)}`, image: item.imageUrl, actions: [ { id: "view", label: "View Details" }, { id: "select", label: "Select", variant: "default" }, ], })), }; },});// Frontend with assistant-uiimport { type Toolkit } from "@assistant-ui/react";import { ItemCarousel } from "@/components/tool-ui/item-carousel";import { safeParseSerializableItemCarousel } from "@/components/tool-ui/item-carousel/schema";import { createResultToolRenderer } from "@/components/tool-ui/shared";export const toolkit: Toolkit = { searchItems: { type: "backend", render: createResultToolRenderer({ safeParse: safeParseSerializableItemCarousel, render: (parsedResult) => ( <> <ItemCarousel {...parsedResult} onItemClick={(id) => router.push(`/items/${id}`)} onItemAction={(itemId, actionId) => { if (actionId === "select") { selectItem(itemId); } }} /> </> ), }), },};Equal-height cards via CSS Grid so items line up regardless of title length
Smooth scroll with snap points and reduced-motion fallback
Buttons stack or sit side-by-side based on card width via container queries
Enter/Space activation, focus rings, and ARIA roles throughout
How the carousel handles common interaction patterns:
Responsive button layout: Each card is a container (@container/card). Narrow cards stack buttons vertically with the primary on top. Above 176px, buttons flow horizontally with the primary on the right. flex-col-reverse keeps DOM order consistent while adapting visual hierarchy.
Equal card heights: Cards use CSS Grid (grid-flow-col with auto-cols-max). Grid cells match heights automatically, so a two-line title aligns with one-line neighbors.
Smooth scroll animation: Custom JavaScript scrolling with a cubic-bezier ease-out curve. Respects prefers-reduced-motion with an instant-scroll fallback. Snap positions are calculated dynamically from card offsets.
Image handling: Accepts image URLs or hex color fallbacks. Images use loading="lazy" and decoding="async". On hover, images scale up 5% with a duration-200 transform transition.
Overlay click target: Interactive cards render a full-card overlay button (absolute inset-0, z-10) for onItemClick, while action buttons sit above it (z-20) so primary actions stay independently clickable.
Touch optimization: touch-manipulation eliminates the 300ms tap delay on iOS. Navigation buttons appear only on hover/focus (desktop) with backdrop blur.
Prop
Type
Prop
Type
prefers-reduced-motion and fall back to instant scrolling