Search Results Tabs
Ranked search results with site icons, titles, source URLs, and plain text category tabs.
Usage
import { SearchResultsTabs } from "@/components/ai/search-results-tabs"
export function Example() {
return <SearchResultsTabs query={query} items={items} activeType={type} showTabs onActiveTypeChange={setType} />
}
Install
pnpm dlx shadcn@latest add @pastaui/search-results-tabsnpx shadcn@latest add @pastaui/search-results-tabsyarn dlx shadcn@latest add @pastaui/search-results-tabsbunx --bun shadcn@latest add @pastaui/search-results-tabsSource
import { GlobalIcon as GlobeIcon } from '@solar-icons/react/linear/global'
import { GalleryIcon as ImageIcon } from '@solar-icons/react/linear/gallery'
import { DocumentTextIcon as NewsIcon } from '@solar-icons/react/linear/document-text'
import { motion, useReducedMotion } from 'motion/react'
import * as React from 'react'
import { cn } from 'cn'
interface SearchResultItem {
id: string
publishedLabel?: string
publishedAt?: string
meta?: string
title: string
type: 'images' | 'news' | 'web'
url?: string
image?: string
imageAlt?: string
source?: string
description?: string
icon?: React.ReactNode
}
interface SearchResultsTabsProps extends React.ComponentProps<'section'> {
activeType?: SearchResultItem['type']
items: SearchResultItem[]
onActiveTypeChange?: (type: SearchResultItem['type']) => void
onOpen?: (id: string) => void
query: string
showTabs?: boolean
}
function domainOf(url?: string) {
if (!url || !/^https?:\/\//.test(url)) return undefined
return url.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]
}
function SearchResultsTabs({
activeType = 'web',
className,
items,
onActiveTypeChange,
onOpen,
query,
showTabs = false,
...props
}: SearchResultsTabsProps) {
const visibleItems = items.filter((item) => item.type === activeType)
const underlineId = React.useId()
const reducedMotion = useReducedMotion()
return (
<section
aria-label={`Search results for ${query}`}
data-slot="search-results-tabs"
className={cn(
'font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground',
className,
)}
{...props}
>
{showTabs && (
<div className="mb-4 flex gap-5 pb-2" aria-label="Result types">
{(['web', 'images', 'news'] as const).map((type) => (
<button
key={type}
type="button"
aria-pressed={activeType === type}
className={cn(
'relative inline-flex cursor-pointer items-center gap-2 bg-transparent px-0 py-2 text-sm font-[450] capitalize transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring',
activeType === type && 'text-foreground',
)}
onClick={() => onActiveTypeChange?.(type)}
>
{type === 'web' ? (
<GlobeIcon aria-hidden="true" className="size-4" />
) : type === 'images' ? (
<ImageIcon aria-hidden="true" className="size-4" />
) : (
<NewsIcon aria-hidden="true" className="size-4" />
)}
{type}
{activeType === type && (
<motion.span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-foreground"
layoutId={underlineId}
transition={
reducedMotion ? { duration: 0 } : { duration: 0.25, ease: [0.77, 0, 0.175, 1] }
}
/>
)}
</button>
))}
</div>
)}
<ol
className={cn(
'transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] starting:opacity-0',
activeType === 'images'
? 'columns-1 gap-4 sm:columns-2 lg:columns-3'
: 'flex max-w-[65ch] flex-col gap-2',
)}
key={activeType}
>
{visibleItems.map((item, index) =>
item.type === 'images' && item.image ? (
<li key={item.id} className="mb-5 break-inside-avoid">
<a
href={item.url}
target="_blank"
rel="noreferrer"
onClick={() => onOpen?.(item.id)}
className="group block"
>
<img
src={item.image}
alt={item.imageAlt ?? item.title}
loading="lazy"
className={cn(
'w-full rounded-[16px] object-cover',
index % 3 === 0
? 'aspect-[4/3]'
: index % 3 === 1
? 'aspect-[3/4]'
: 'aspect-square',
)}
/>
<div className="pt-2">
<p className="truncate card-heading text-foreground group-hover:underline">
{item.title}
</p>
<div className="mt-1 flex items-center gap-1.5 text-xs leading-5">
<span
aria-hidden="true"
className="flex size-4 shrink-0 items-center justify-center"
>
{item.icon ?? <GlobeIcon className="size-4" />}
</span>
<span>{item.source ?? 'Unsplash'}</span>
</div>
</div>
</a>
</li>
) : (
<li
className="group relative -mx-3 rounded-[12px] p-3 transition-colors duration-150 ease-out hover:bg-foreground/4"
key={item.id}
>
<div className="flex items-center gap-2 text-[13px] leading-5 text-muted-foreground">
<span
aria-hidden="true"
className="flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px] bg-foreground/6 [&_img]:size-3.5 [&_img]:object-contain [&_svg]:size-3.5"
>
{item.icon ?? <GlobeIcon />}
</span>
<span className="truncate">
{[item.source ?? item.meta, domainOf(item.url)].filter(Boolean).join(' · ')}
</span>
</div>
<h3 className="mt-1 text-base leading-6 font-medium text-foreground">
{item.type === 'news' ? (
item.title
) : item.url ? (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="decoration-foreground/40 underline-offset-4 outline-none group-hover:underline after:absolute after:inset-0 after:rounded-[12px] visited:text-foreground/70 focus-visible:after:ring-2 focus-visible:after:ring-ring"
onClick={() => onOpen?.(item.id)}
>
{item.title}
</a>
) : (
<button
type="button"
className="text-left decoration-foreground/40 underline-offset-4 outline-none group-hover:underline after:absolute after:inset-0 after:rounded-[12px] focus-visible:after:ring-2 focus-visible:after:ring-ring"
onClick={() => onOpen?.(item.id)}
>
{item.title}
</button>
)}
</h3>
{item.description && (
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-muted-foreground">
{item.description}
</p>
)}
{item.type === 'news' && item.publishedLabel && (
<time
dateTime={item.publishedAt}
className="mt-1.5 block text-[13px] leading-5 text-muted-foreground/70"
>
{item.publishedLabel}
</time>
)}
</li>
),
)}
</ol>
{!visibleItems.length && (
<p className="py-8 text-center text-xs leading-5">No results found.</p>
)}
</section>
)
}
export { SearchResultsTabs }
export type { SearchResultItem, SearchResultsTabsProps }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| query | string | = — | Names the search results for assistive technology. |
| items | SearchResultItem[] | = — | Supplies typed search results. News items support publishedLabel for relative time and optional publishedAt for an ISO timestamp. |
| activeType | "web" | "images" | "news" | = — | Filters results by type. Defaults to web. |
| showTabs | boolean | = false | Shows optional Web, Images, and News filters. |
| onOpen | (id: string) => void | = — | Opens a result. |