Navigation
Apple Spotlight
#spotlight#search#apple
Search
implementedInfinityUI component
This component is fully implemented in InfinityUI and wired into the docs and registry flow.
Installation
Use the registry command to add the component source, and install any package dependencies if needed.
Infinity Registry
Install the component source into your project with the shadcn CLI.
npx shadcn@latest add https://infinityui-pearl.vercel.app/r/apple-spotlightPackage Dependencies
Install the npm packages used by this component source.
npm install clsx framer-motion lucide-react tailwind-mergeComponent Code
Copy and paste this code into your component file.
tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Activity,
Calendar,
ChevronRight,
Files,
Folder,
Globe,
Image as ImageIcon,
LayoutGrid,
Mail,
MessageSquare,
Music,
Search,
Settings,
StickyNote,
Terminal,
Twitter,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Shortcut = {
label: string;
icon: React.ReactNode;
link: string;
};
type SearchResult = {
icon: React.ReactNode;
label: string;
description: string;
link: string;
};
const defaultShortcuts: Shortcut[] = [
{ label: "Apps", icon: <LayoutGrid />, link: "#" },
{ label: "Files", icon: <Folder />, link: "#" },
{ label: "Actions", icon: <Activity />, link: "#" },
{ label: "Clipboard", icon: <Files />, link: "#" },
];
const defaultSearchResults: SearchResult[] = [
{ icon: <Twitter />, label: "Twitter", description: "Open Twitter", link: "#" },
{ icon: <Globe />, label: "Safari", description: "Open web browser", link: "#" },
{ icon: <Mail />, label: "Mail", description: "Open Mail", link: "#" },
{ icon: <Calendar />, label: "Calendar", description: "View calendar", link: "#" },
{ icon: <StickyNote />, label: "Notes", description: "Open Notes", link: "#" },
{ icon: <ImageIcon />, label: "Photos", description: "Browse photos", link: "#" },
{ icon: <Settings />, label: "Settings", description: "Open Settings", link: "#" },
{ icon: <Terminal />, label: "Terminal", description: "Open Terminal", link: "#" },
{ icon: <Folder />, label: "Finder", description: "Open Finder", link: "#" },
{ icon: <MessageSquare />, label: "Messages", description: "Open Messages", link: "#" },
{ icon: <Music />, label: "Music", description: "Open Music", link: "#" },
];
function SvgFilter() {
return (
<svg width="0" height="0" aria-hidden="true">
<filter id="apple-spotlight-blob">
<feGaussianBlur stdDeviation="10" in="SourceGraphic" />
<feColorMatrix
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -9"
result="blob"
/>
<feBlend in="SourceGraphic" in2="blob" />
</filter>
</svg>
);
}
function ShortcutButton({ icon, link }: { icon: React.ReactNode; link: string }) {
return (
<a href={link} target="_blank" rel="noreferrer" className="rounded-full">
<div className="rounded-full cursor-pointer opacity-30 transition-[opacity,shadow] duration-200 hover:opacity-100 hover:shadow-lg">
<div className="size-16 aspect-square flex items-center justify-center">
{icon}
</div>
</div>
</a>
);
}
function SpotlightPlaceholder({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<motion.div
layout
className={cn(
"absolute z-10 flex items-center text-gray-500 pointer-events-none",
className
)}
>
<AnimatePresence mode="popLayout">
<motion.p
layoutId={`placeholder-${text}`}
key={`placeholder-${text}`}
initial={{ opacity: 0, y: 10, filter: "blur(5px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -10, filter: "blur(5px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{text}
</motion.p>
</AnimatePresence>
</motion.div>
);
}
function SpotlightInput({
placeholder,
hidePlaceholder,
value,
onChange,
placeholderClassName,
}: {
placeholder: string;
hidePlaceholder: boolean;
value: string;
onChange: (value: string) => void;
placeholderClassName?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="flex items-center justify-start w-full gap-2 px-6 h-16">
<motion.div layoutId="search-icon">
<Search />
</motion.div>
<div className="relative flex-1 text-2xl">
{!hidePlaceholder && (
<SpotlightPlaceholder
text={placeholder}
className={placeholderClassName}
/>
)}
<motion.input
ref={inputRef}
layout="position"
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full bg-transparent outline-none ring-none"
/>
</div>
</div>
);
}
function SearchResultCard({
icon,
label,
description,
link,
isLast,
}: SearchResult & { isLast: boolean }) {
return (
<a
href={link}
target="_blank"
rel="noreferrer"
className="w-full overflow-hidden group/card"
>
<div
className={cn(
"flex w-full items-center justify-start gap-3 rounded-xl px-2 py-2 text-black transition-colors hover:bg-white hover:shadow-md",
isLast && "rounded-b-3xl"
)}
>
<div className="size-8 aspect-square flex items-center justify-center [&_svg]:size-6 [&_svg]:stroke-[1.5]">
{icon}
</div>
<div className="flex flex-col">
<p className="font-medium">{label}</p>
<p className="text-xs opacity-50">{description}</p>
</div>
<div className="flex items-center justify-end flex-1 transition-opacity opacity-0 group-hover/card:opacity-100 duration-200">
<ChevronRight className="size-6" />
</div>
</div>
</a>
);
}
function SearchResultsContainer({
searchResults,
onHover,
}: {
searchResults: SearchResult[];
onHover: (index: number | null) => void;
}) {
return (
<motion.div
layout
onMouseLeave={() => onHover(null)}
className="flex flex-col w-full max-h-96 px-2 py-2 overflow-y-auto border-t bg-neutral-100"
>
{searchResults.map((result, index) => (
<motion.div
key={`search-result-${index}`}
onMouseEnter={() => onHover(index)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
delay: index * 0.1,
duration: 0.2,
ease: "easeOut",
}}
>
<SearchResultCard
{...result}
isLast={index === searchResults.length - 1}
/>
</motion.div>
))}
</motion.div>
);
}
export interface AppleSpotlightProps {
shortcuts?: Shortcut[];
isOpen?: boolean;
handleClose?: () => void;
fullscreen?: boolean;
className?: string;
}
export function AppleSpotlight({
shortcuts = defaultShortcuts,
isOpen = true,
handleClose = () => undefined,
fullscreen = true,
className,
}: AppleSpotlightProps) {
const [hovered, setHovered] = useState(false);
const [hoveredSearchResult, setHoveredSearchResult] = useState<number | null>(null);
const [hoveredShortcut, setHoveredShortcut] = useState<number | null>(null);
const [searchValue, setSearchValue] = useState("");
return (
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
initial={{
opacity: 0,
filter: "blur(20px)",
scaleX: 1.3,
scaleY: 1.1,
y: -10,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
scaleX: 1,
scaleY: 1,
y: 0,
}}
exit={{
opacity: 0,
filter: "blur(20px)",
scaleX: 1.3,
scaleY: 1.1,
y: 10,
}}
transition={{ stiffness: 550, damping: 50, type: "spring" }}
className={cn(
"z-20 flex flex-col items-center justify-center",
fullscreen ? "fixed inset-0" : "absolute inset-0",
className
)}
onClick={handleClose}
>
<SvgFilter />
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => {
setHovered(false);
setHoveredShortcut(null);
}}
onClick={(event) => event.stopPropagation()}
className={cn(
"group z-20 flex w-full max-w-3xl items-center justify-end gap-4",
"[&>div]:bg-neutral-100 [&>div]:text-black [&>div]:rounded-full [&>div]:backdrop-blur-xl",
"[&_svg]:size-7 [&_svg]:stroke-[1.4]"
)}
>
<AnimatePresence mode="popLayout">
<motion.div
layoutId="search-input-container"
transition={{
layout: {
duration: 0.5,
type: "spring",
bounce: 0.2,
},
}}
style={{ borderRadius: "30px" }}
className="relative z-10 flex flex-col items-center justify-start w-full h-full overflow-hidden border shadow-lg"
>
<SpotlightInput
placeholder={
hoveredShortcut !== null
? shortcuts[hoveredShortcut].label
: hoveredSearchResult !== null
? defaultSearchResults[hoveredSearchResult].label
: "Search"
}
placeholderClassName={
hoveredSearchResult !== null
? "bg-white text-black"
: "text-gray-500"
}
hidePlaceholder={
!(
hoveredSearchResult !== null ||
!searchValue
)
}
value={searchValue}
onChange={setSearchValue}
/>
{searchValue && (
<SearchResultsContainer
searchResults={defaultSearchResults}
onHover={setHoveredSearchResult}
/>
)}
</motion.div>
{hovered &&
!searchValue &&
shortcuts.map((shortcut, index) => (
<motion.div
key={`shortcut-${index}`}
onMouseEnter={() => setHoveredShortcut(index)}
layout
initial={{
scale: 0.7,
x: -1 * (64 * (index + 1)),
}}
animate={{ scale: 1, x: 0 }}
exit={{
scale: 0.7,
x:
1 *
(16 *
(shortcuts.length - index - 1) +
64 *
(shortcuts.length -
index -
1)),
}}
transition={{
duration: 0.8,
type: "spring",
bounce: 0.2,
delay: index * 0.05,
}}
className="rounded-full cursor-pointer"
>
<ShortcutButton
icon={shortcut.icon}
link={shortcut.link}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export default AppleSpotlight;