diff --git a/src/pages/search/empty-states.tsx b/src/pages/search/empty-states.tsx
new file mode 100644
index 0000000..5c6969a
--- /dev/null
+++ b/src/pages/search/empty-states.tsx
@@ -0,0 +1,158 @@
+import { motion } from 'framer-motion'
+import { Search, Clock } from '../../lib/icons'
+import { usePosterGridClasses } from '../../lib/density'
+
+const SUGGESTED: string[] = [
+ 'Top action movies',
+ 'Christopher Nolan',
+ 'Oscar winners',
+ 'A24',
+ 'Studio Ghibli',
+ 'Denis Villeneuve',
+]
+
+export function EmptyState({
+ recents,
+ onPick,
+ onClearRecents,
+}: { recents: string[]; onPick: (s: string) => void; onClearRecents: () => void }) {
+ return (
+
+
+
+
+ What are you looking for?
+
+
+ Search across your library and TMDB for movies, shows, episodes, people, and music.
+
+
+
+ {recents.length > 0 && (
+
+
+
+ Recent
+
+
+
+
+ {recents.map(r => (
+
+ ))}
+
+
+ )}
+
+
+
+ Try searching
+
+
+ {SUGGESTED.map(s => (
+
+ ))}
+
+
+
+ )
+}
+
+export function LoadingState() {
+ const grid = usePosterGridClasses()
+ return (
+
+ {/* People skeleton */}
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Posters skeleton */}
+
+
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+
+
+ )
+}
+
+export function NoResults({ query }: { query: string }) {
+ return (
+
+
+
+ Nothing matched "{query}"
+
+
+ Check the spelling, try a shorter query, or search for an actor or director.
+
+
+ )
+}
diff --git a/src/pages/search/sections.tsx b/src/pages/search/sections.tsx
new file mode 100644
index 0000000..aa969a1
--- /dev/null
+++ b/src/pages/search/sections.tsx
@@ -0,0 +1,337 @@
+import { useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { User, Clock, Calendar, Star, Disc3, Music, FileVideo } from '../../lib/icons'
+import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
+import { getTmdbImageUrl } from '../../api/tmdb'
+import { formatRuntime } from '../../lib/format'
+import { usePosterGridClasses } from '../../lib/density'
+import PosterCard from '../../components/ui/PosterCard'
+import type { BaseItemDto } from '../../api/types'
+
+export function SectionHeader({
+ label,
+ hint,
+ count,
+}: { label: string; hint?: string; count?: number }) {
+ return (
+
+
+
+ {label}
+
+ {hint && {hint}}
+
+ {count != null && (
+
{count}
+ )}
+
+ )
+}
+
+interface TmdbPerson {
+ id: number
+ name?: string
+ profile_path?: string | null
+ known_for_department?: string
+ known_for?: Array<{ title?: string; name?: string }>
+}
+
+export function PeopleSection({ people }: { people: TmdbPerson[] }) {
+ const navigate = useNavigate()
+ return (
+
+
+
+ {people.map((p, i) => {
+ const knownFor = (p.known_for || [])
+ .slice(0, 2)
+ .map(k => k.title || k.name)
+ .filter(Boolean)
+ .join(', ')
+ return (
+
navigate(`/person/${p.id}`)}
+ initial={{ opacity: 0, y: 6 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.24, delay: Math.min(i * 0.025, 0.2), ease: [0.16, 1, 0.3, 1] }}
+ className="group flex items-center gap-4 p-2.5 -mx-2.5 rounded-xl hover:bg-elevated/50 transition-colors duration-200 text-left focus-ring"
+ >
+
+ {p.profile_path ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {p.name}
+
+ {p.known_for_department && (
+
+ {p.known_for_department}
+
+ )}
+ {knownFor && (
+
+ {knownFor}
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
+
+export function MoviesShowsSection({
+ cards,
+ localCount,
+ tmdbExtraCount,
+}: { cards: BaseItemDto[]; localCount: number; tmdbExtraCount: number }) {
+ const navigate = useNavigate()
+ const grid = usePosterGridClasses()
+ const hint =
+ localCount && tmdbExtraCount
+ ? `${localCount} in your library, ${tmdbExtraCount} on TMDB`
+ : localCount
+ ? `${localCount} in your library`
+ : tmdbExtraCount
+ ? `${tmdbExtraCount} on TMDB`
+ : undefined
+ return (
+
+
+
+ {cards.map((item, i) => (
+
+ item.Id && navigate(`/item/${item.Id}`)}
+ />
+
+ ))}
+
+
+ )
+}
+
+export function EpisodesSection({ items }: { items: BaseItemDto[] }) {
+ const navigate = useNavigate()
+ const serverUrl = getStoredServerUrl()
+
+ return (
+
+
+
+ {items.map((ep, i) => {
+ // Episode still lives in the Primary image type for Episodes;
+ // Thumb is rarely populated. Try Primary first, fall back to
+ // backdrop (which falls back to parent series), then thumb.
+ const thumb =
+ getBestImage(serverUrl, ep, 'primary', 480) ||
+ getBestImage(serverUrl, ep, 'backdrop', 480) ||
+ getBestImage(serverUrl, ep, 'thumb', 480) ||
+ ''
+ const seasonEp =
+ ep.ParentIndexNumber != null && ep.IndexNumber != null
+ ? `S${String(ep.ParentIndexNumber).padStart(2, '0')}E${String(ep.IndexNumber).padStart(2, '0')}`
+ : ''
+ const seriesName = ep.SeriesName || ''
+ const runtime = ep.RunTimeTicks ? formatRuntime(ep.RunTimeTicks) : ''
+ return (
+
ep.Id && navigate(`/item/${ep.Id}`)}
+ initial={{ opacity: 0, y: 6 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.26, delay: Math.min(i * 0.02, 0.2), ease: [0.16, 1, 0.3, 1] }}
+ className="group flex items-stretch gap-4 p-2.5 rounded-xl bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition-all duration-200 text-left focus-ring"
+ >
+
+ {thumb ? (
+

+ ) : (
+
+
+
+ )}
+ {seasonEp && (
+
+ {seasonEp}
+
+ )}
+
+
+ {seriesName && (
+
+ {seriesName}
+
+ )}
+
+ {ep.Name || 'Untitled'}
+
+
+ {runtime && (
+
+ {runtime}
+
+ )}
+ {ep.ProductionYear && (
+
+ {ep.ProductionYear}
+
+ )}
+ {typeof ep.CommunityRating === 'number' && (
+
+
+ {ep.CommunityRating.toFixed(1)}
+
+ )}
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export function MusicSection({
+ albums,
+ artists,
+ tracks,
+}: { albums: BaseItemDto[]; artists: BaseItemDto[]; tracks: BaseItemDto[] }) {
+ return (
+
+ {artists.length > 0 && }
+ {albums.length > 0 && }
+ {tracks.length > 0 && }
+
+ )
+}
+
+function MusicGrid({
+ label,
+ items,
+ circular,
+ icon: Icon,
+}: {
+ label: string
+ items: BaseItemDto[]
+ circular?: boolean
+ icon: typeof User
+}) {
+ const navigate = useNavigate()
+ const serverUrl = getStoredServerUrl()
+ return (
+
+
+
+ {items.map((item, i) => {
+ const img = getBestImage(serverUrl, item, 'primary', 240)
+ const augmented = item as BaseItemDto & { AlbumArtist?: string; Artists?: string[] }
+ const subtitle =
+ augmented.AlbumArtist ||
+ (Array.isArray(augmented.Artists) ? augmented.Artists.slice(0, 2).join(', ') : '') ||
+ ''
+ return (
+
item.Id && navigate(`/item/${item.Id}`)}
+ initial={{ opacity: 0, y: 8 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.28, delay: Math.min(i * 0.025, 0.2), ease: [0.16, 1, 0.3, 1] }}
+ className="group text-left focus-ring rounded-lg"
+ >
+
+ {img ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {item.Name || 'Untitled'}
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
+function TrackList({ items }: { items: BaseItemDto[] }) {
+ const navigate = useNavigate()
+ const serverUrl = getStoredServerUrl()
+ return (
+
+
+
+ {items.map((item, i) => {
+ const img = getBestImage(serverUrl, item, 'primary', 96)
+ const augmented = item as BaseItemDto & { AlbumArtist?: string; Artists?: string[]; Album?: string }
+ const sub = [
+ augmented.AlbumArtist || (Array.isArray(augmented.Artists) ? augmented.Artists[0] : ''),
+ augmented.Album,
+ ].filter(Boolean).join(' - ')
+ const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : ''
+ return (
+
item.Id && navigate(`/item/${item.Id}`)}
+ initial={{ opacity: 0, y: 4 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.22, delay: Math.min(i * 0.02, 0.2), ease: [0.16, 1, 0.3, 1] }}
+ className="group flex items-center gap-3 p-2 rounded-lg hover:bg-elevated/60 transition-colors duration-150 text-left focus-ring"
+ >
+
+ {img ? (
+

+ ) : (
+
+ )}
+
+
+
+ {item.Name || 'Untitled'}
+
+ {sub &&
{sub}
}
+
+ {runtime && (
+ {runtime}
+ )}
+
+ )
+ })}
+
+
+ )
+}