INFINITYUI
HomeComponentsDocsTemplates
Star

Getting Started

  • Introduction
  • Installation
  • CLI
  • Audit

Navigation

  • Spotlight Navbar
  • Glass DockNEW
  • Animated Tab Bar
  • Circle Menu
  • Magnet Tabs
  • Animated Sidebar
  • Apple Spotlight
  • Page TOC RailNEW

Text

  • Flip Text
  • Glitch Text
  • Liquid Text
  • Flip Fade Text
  • Mask Cursor Effect

Cards

  • Glow Border Card
  • Testimonials Card
  • Interactive Book
  • Trading CardsNEW
  • Hover Image
  • Chain of ThoughtNEW
  • Masonry Grid
  • Image Pile
  • Staggered Grid

Inputs

  • AI InputNEW
  • OTP Input
  • Leave Rating

Buttons

  • Social Flip Button
  • Creepy Button

Loaders

  • Jelly Loader
  • Rolling Ball Scroll
  • Glowing Scroll

Backgrounds

  • Light Lines
  • Perspective Grid
  • Liquid Ocean
  • Eagle Vision
  • Flow Scroll
  • Horizontal Scroll

Overlays

  • PersonaNEW
  • Infinite Moving Cards
  • Masked Avatars
  • Stacked Logos
  • Icon Wheel
  • Pixelated CarouselNEW
  • Pixelated Image Trail
  • Flip Scroll
  • Interactive Folder
  • Animated Folder IconNEW
  • Stack Scroll
  • Rubik Cube
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-spotlight
Package Dependencies

Install the npm packages used by this component source.

npm install clsx framer-motion lucide-react tailwind-merge

Component 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;