Interactive Project Explorer
An IDE-style project explorer with collapsible year folders, status badges, and floating cursor image previews.
Props
Installation
Interactive Project Explorer is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.
$npx shadcn@latest add https://www.shreejoybikalp.in/r/project-explorer.jsonThe command fetches the component straight from shreejoybikalp.in, so there is nothing to configure first. Install any other component the same way from the Components page.
Usage
import { ProjectExplorer } from "@/components/project-explorer";
const projects = [
{
id: "nav",
title: "NAV: cloud for builders",
year: 2026,
status: "new",
category: "infrastructure",
href: "https://dashboard.navhost.duckdns.org",
image: "/work/nav-home.webp",
},
{
id: "nyaiy",
title: "NyAIy: your legal copilot",
year: 2025,
status: "shipped",
category: "ai",
href: "https://nyaiy.vercel.app",
},
];
export function PortfolioProjects() {
return (
<div className="max-w-2xl mx-auto">
<ProjectExplorer
projects={projects}
showHoverPreview={true}
defaultOpen="latest"
/>
</div>
);
}Features & Anatomy
The Interactive Project Explorer organizes work chronologically with paper-cut folders and newspaper-style image previews.
- Year folders with animated expansion. Projects are categorized by year in collapsible folder groups with illustrated open and closed folders, halftone shading, and paper-cut year labels. Accent colors cycle between years, and hover movement respects reduced motion.
- Spring cursor image preview. Hovering or focusing any project row triggers a newspaper-style clipping with irregular edges, a subtle print texture, and a serif caption. It follows the cursor using smooth Motion spring values (
stiffness: 260, damping: 26) with edge-boundary detection. - Configurable Hover Preview & Image Fallback. Toggle cursor preview via
showHoverPreview. When enabled on items without an image URL, a clean LucideImageIconfallback placeholder is rendered automatically. - Status Pills. Highlights project release states such as
building...(with animated radar ping indicator),new, andshipped. - Flat, bare rows. Each row shows the project name, status, and link arrow. A title written as
Name: taglineshows only the name in the row; the tagline appears on the preview card, on hover or while a finger is held on the row. Hierarchy reads through indentation, without dividers or category tags. - Full Accessibility & Reduced-Motion. Disables cursor floating previews gracefully when
prefers-reduced-motionis detected or on non-hover touch devices.
Props & Options
| Prop | Type | Default | Description |
|---|---|---|---|
projects | ProjectExplorerItem[] | sampleProjects | Array of project items to display. |
title | string | "Featured Projects" | Section heading text. |
showHeading | boolean | true | Show or hide the top section heading rule and title. |
defaultOpen | "all" | "latest" | "all" | Open all folders or only the most recent year. |
showHoverPreview | boolean | true | Toggle floating cursor preview cards on row hover/focus. |
className | string | undefined | Additional CSS classes for the root container. |
Data Structure
export type ProjectStatus = "building" | "new" | "shipped";
export interface ProjectExplorerItem {
id: string;
title: string;
year: number;
description?: string;
category?: string;
status?: ProjectStatus;
href?: string;
liveUrl?: string;
githubUrl?: string;
image?: string;
imageAlt?: string;
enabled?: boolean;
order?: number;
}Manual installation
Rather not use the CLI? Everything Interactive Project Explorer needs is below. Install its dependencies, then copy the file into the matching path in your project.
Dependencies
$npm install lucide-react motionSource
"use client";
import { cn } from "@/lib/utils";
import {
ArrowUpRight,
ImageIcon,
} from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
useSpring,
} from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
export type ProjectStatus = "building" | "new" | "shipped";
export interface ProjectExplorerItem {
id: string;
title: string;
year: number;
description?: string;
category?: string;
status?: ProjectStatus;
href?: string;
liveUrl?: string;
githubUrl?: string;
image?: string;
imageAlt?: string;
/** "portrait" for a phone screenshot: the preview card turns tall and narrow. */
imageOrientation?: "landscape" | "portrait";
showImage?: boolean;
enabled?: boolean;
order?: number;
}
const defaultSampleProjects: ProjectExplorerItem[] = [
{
id: "nav",
title: "NAV: cloud for builders",
year: 2026,
status: "new",
category: "infrastructure",
href: "https://dashboard.navhost.duckdns.org",
image: "/work/nav-home.webp",
},
{
id: "metaswap",
title: "MetaSwap: a full decentralised exchange",
year: 2026,
status: "new",
category: "web3",
href: "https://metaswaplabs.duckdns.org/swap",
image: "/work/metaswap-home.webp",
},
{
id: "kortex",
title: "KORTEX AI: scan-and-go checkout verification",
year: 2025,
status: "shipped",
category: "computer-vision",
href: "https://kortex-ai-one.vercel.app/login",
image: "/work/kortex.webp",
},
{
id: "nyaiy",
title: "NyAIy: your legal copilot",
year: 2025,
status: "shipped",
category: "ai",
href: "https://nyaiy.vercel.app",
image: "/work/nyaiy.webp",
},
];
const HOVER_QUERY = "(hover: hover) and (pointer: fine)";
function subscribeHoverCapability(callback: () => void) {
const mql = window.matchMedia(HOVER_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
const getHoverSnapshot = () => window.matchMedia(HOVER_QUERY).matches;
const getServerSnapshot = () => false;
const PREVIEW_WIDTH = 300;
const PREVIEW_HEIGHT = 264;
/** A phone screenshot keeps its shape: a narrow card with a tall image. */
const PORTRAIT_PREVIEW_WIDTH = 200;
const PORTRAIT_PREVIEW_HEIGHT = 470;
function previewSize(project: ProjectExplorerItem | null) {
return project?.imageOrientation === "portrait"
? { width: PORTRAIT_PREVIEW_WIDTH, height: PORTRAIT_PREVIEW_HEIGHT }
: { width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT };
}
/** How long a finger must rest on a row before its preview card appears. */
const HOLD_MS = 380;
/**
* Splits "Name: tagline" (or the older "Name — tagline") into its parts. The
* row shows only the name; the tagline lives on the preview card.
*/
function splitTitle(title: string): { name: string; tagline?: string } {
const match = /^(.+?)(?::\s+|\s+—\s+)(.+)$/.exec(title);
if (!match) return { name: title };
const tagline = match[2].trim();
return {
name: match[1].trim(),
tagline: tagline.charAt(0).toUpperCase() + tagline.slice(1),
};
}
const PAPER_EDGE =
"polygon(1% 2%, 12% 0.5%, 25% 1.5%, 38% 0%, 54% 1%, 69% 0.5%, 83% 1.5%, 98% 0%, 99% 14%, 98.5% 31%, 100% 48%, 99% 65%, 99.5% 82%, 98% 99%, 84% 98%, 70% 100%, 55% 98.5%, 40% 99.5%, 25% 98%, 11% 99.5%, 0% 98%, 1% 82%, 0% 65%, 1.5% 48%, 0.5% 31%, 1% 15%)";
const PAPER_TEXTURE = {
backgroundImage:
"radial-gradient(var(--foreground, #171717) 0.45px, transparent 0.65px)",
backgroundSize: "3px 3px",
};
function PaperFolderIcon({
open,
className,
}: {
open: boolean;
className?: string;
}) {
const patternId = useId();
const silhouette = "m4 7 15-2 5 5 17-1 2 5 3 1-6 21-33 1-3-5Z";
const front = open
? "M4 17 15 16 18 18 43 15 37 33 8 34Z"
: "M7 15 18 14 21 16 39 15 38 33 8 34Z";
return (
<svg
viewBox="0 0 48 40"
aria-hidden="true"
focusable="false"
color="var(--cutout-ink, var(--foreground))"
className={cn(
"h-8 w-10 shrink-0 overflow-visible",
"[--cutout-paper:var(--background)] dark:[--cutout-paper:var(--foreground)] dark:[--cutout-ink:var(--background)]",
className,
)}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<defs>
<pattern
id={patternId}
width="3"
height="3"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="0.5" className="fill-current opacity-30" />
</pattern>
</defs>
<g stroke="currentColor">
<path
d={silhouette}
transform="translate(1 2)"
fill="currentColor"
strokeWidth="4"
opacity="0.1"
/>
<path
d={silhouette}
fill="var(--cutout-paper, var(--background))"
stroke="var(--cutout-paper, var(--background))"
strokeWidth="4"
/>
<path
d={silhouette}
fill="color-mix(in oklch, var(--chart-2, #3b82f6) 14%, var(--cutout-paper, var(--background)))"
/>
{open && (
<path
d="m7 12 12-2 5 5 15-1-1 19-29 2Z"
fill="currentColor"
fillOpacity="0.12"
strokeWidth="1"
/>
)}
<path
d="m8 10 9-1 3 3-12 1Z"
fill="var(--chart-2, #3b82f6)"
fillOpacity="0.85"
strokeWidth="0.8"
/>
<path
d={front}
fill="var(--cutout-paper, var(--background))"
strokeWidth="1.2"
/>
<path d={front} fill={`url(#${patternId})`} stroke="none" />
<path
d="m12 21 10-1v4l-10 1Z"
fill="var(--chart-1, #f97316)"
fillOpacity="0.7"
stroke="none"
/>
<path
d="m12 28 20-1m-20 3 14-1"
strokeWidth="0.7"
opacity="0.55"
/>
</g>
</svg>
);
}
function StatusBadge({ status }: { status: ProjectStatus }) {
if (status === "shipped") return null;
if (status === "building") {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-sm bg-blue-500/10 px-1.5 py-0.5 font-sans text-[10px] leading-none text-blue-600 dark:text-blue-400">
<span className="relative flex size-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-60 motion-reduce:animate-none" />
<span className="relative inline-flex size-1.5 rounded-full bg-blue-500" />
</span>
building…
</span>
);
}
return (
<span className="inline-flex shrink-0 items-center rounded-sm bg-amber-500/10 px-1.5 py-0.5 font-sans text-[10px] leading-none text-amber-600 dark:text-amber-400">
new
</span>
);
}
interface PreviewHandlers {
onPreviewStart: (project: ProjectExplorerItem, anchor?: DOMRect) => void;
onHoldStart: (project: ProjectExplorerItem, anchor: DOMRect) => void;
onPreviewEnd: () => void;
showHoverPreview: boolean;
onProjectClick?: (project: ProjectExplorerItem, targetType: "case_study" | "external_url", url: string) => void;
}
function WorkRow({
project,
onPreviewStart,
onHoldStart,
onPreviewEnd,
showHoverPreview,
onProjectClick,
}: { project: ProjectExplorerItem } & PreviewHandlers) {
const { name } = splitTitle(project.title);
// Press and hold on touch screens shows the preview card while the finger
// stays down; the tap that ends a hold does not open the project.
const holdTimer = useRef<number | undefined>(undefined);
const touching = useRef(false);
const held = useRef(false);
useEffect(() => () => window.clearTimeout(holdTimer.current), []);
const startHold = (event: React.TouchEvent<HTMLAnchorElement>) => {
touching.current = true;
held.current = false;
const anchor = event.currentTarget.getBoundingClientRect();
window.clearTimeout(holdTimer.current);
holdTimer.current = window.setTimeout(() => {
held.current = true;
onHoldStart(project, anchor);
}, HOLD_MS);
};
const cancelPendingHold = () => {
if (!held.current) window.clearTimeout(holdTimer.current);
};
const endHold = () => {
touching.current = false;
window.clearTimeout(holdTimer.current);
if (held.current) onPreviewEnd();
};
// A row opens the product itself: the live site, or the source code for
// projects that are not hosted. The case study is the last resort.
const href =
project.href ??
project.liveUrl ??
project.githubUrl ??
(project.id ? `/project/${project.id}` : "#");
const isExternal = /^https?:\/\//.test(href);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (held.current) {
event.preventDefault();
held.current = false;
return;
}
onProjectClick?.(project, isExternal ? "external_url" : "case_study", href);
};
return (
<li className="relative">
<Link
href={href}
aria-label={project.title}
onClick={handleClick}
onTouchStart={showHoverPreview ? startHold : undefined}
onTouchMove={showHoverPreview ? cancelPendingHold : undefined}
onTouchEnd={showHoverPreview ? endHold : undefined}
onTouchCancel={showHoverPreview ? endHold : undefined}
onContextMenu={(event) => {
// A long press would otherwise open the browser's link menu.
if (touching.current) event.preventDefault();
}}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
onMouseEnter={
showHoverPreview ? () => onPreviewStart(project) : undefined
}
onMouseLeave={showHoverPreview ? onPreviewEnd : undefined}
onFocus={
showHoverPreview
? (event) =>
onPreviewStart(
project,
event.currentTarget.getBoundingClientRect(),
)
: undefined
}
onBlur={showHoverPreview ? onPreviewEnd : undefined}
className="group micro-transition flex min-h-11 select-none items-center gap-2 rounded-sm px-3 py-2 [-webkit-touch-callout:none] hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/20"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary group-focus-visible:text-primary">
{name}
</span>
{project.status ? <StatusBadge status={project.status} /> : null}
<ArrowUpRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/30 transition-all duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-primary group-focus-visible:-translate-y-0.5 group-focus-visible:translate-x-0.5 group-focus-visible:text-primary"
/>
</Link>
</li>
);
}
export type ProjectExplorerProps = {
/** The list of projects to display. If omitted, sample projects will be shown. */
projects?: ProjectExplorerItem[];
/** Section heading text. Default: "Featured Projects". */
title?: string;
/** Hide the section label when the explorer lives inside an already-labelled surface.
* Also drops the section's top rule. */
showHeading?: boolean;
/** Choose whether every year or only the most recent year starts expanded. */
defaultOpen?: "all" | "latest";
/** Enable or disable the floating cursor hover image preview card. Default: true */
showHoverPreview?: boolean;
/** Optional callback fired when a project link is clicked. */
onProjectClick?: (project: ProjectExplorerItem, targetType: "case_study" | "external_url", url: string) => void;
/** Optional callback fired when a year folder is expanded or collapsed. */
onYearToggle?: (year: number, action: "expand" | "collapse") => void;
/** Optional DOM id for the section container */
id?: string;
/** Additional classes for the container section */
className?: string;
};
export function ProjectExplorer({
projects = defaultSampleProjects,
title = "Featured Projects",
showHeading = true,
defaultOpen = "all",
showHoverPreview = true,
id,
onProjectClick,
onYearToggle,
className,
}: ProjectExplorerProps) {
const [activeProject, setActiveProject] = useState<ProjectExplorerItem | null>(null);
const projectsByYear = useMemo(() => {
const enabledProjects = projects
.filter((item) => item.enabled !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return enabledProjects
.reduce<{ year: number; projects: ProjectExplorerItem[] }[]>((groups, project) => {
const group = groups.find((g) => g.year === project.year);
if (group) {
group.projects.push(project);
} else {
groups.push({ year: project.year, projects: [project] });
}
return groups;
}, [])
.sort((a, b) => b.year - a.year);
}, [projects]);
const [openYears, setOpenYears] = useState<Record<number, boolean>>(() => {
const initial: Record<number, boolean> = {};
projectsByYear.forEach((group, index) => {
initial[group.year] = defaultOpen === "all" || index === 0;
});
return initial;
});
const toggleYear = useCallback((year: number) => {
setOpenYears((prev) => {
const willBeOpen = !prev[year];
onYearToggle?.(year, willBeOpen ? "expand" : "collapse");
return {
...prev,
[year]: willBeOpen,
};
});
}, [onYearToggle]);
const canHover = useSyncExternalStore(
subscribeHoverCapability,
getHoverSnapshot,
getServerSnapshot,
);
const prefersReducedMotion = useReducedMotion();
const springX = useSpring(0, { stiffness: 260, damping: 26, mass: 0.6 });
const springY = useSpring(0, { stiffness: 260, damping: 26, mass: 0.6 });
useEffect(() => {
const hide = () => setActiveProject(null);
window.addEventListener("scroll", hide, { passive: true });
return () => window.removeEventListener("scroll", hide);
}, []);
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
if (!showHoverPreview || !activeProject) return;
const { width, height } = previewSize(activeProject);
const flip = event.clientX > window.innerWidth - width - 48;
springX.set(event.clientX + (flip ? -(width + 24) : 24));
springY.set(
Math.max(
16,
Math.min(
event.clientY - height / 2,
window.innerHeight - height - 24,
),
),
);
},
[springX, springY, showHoverPreview, activeProject],
);
const handlePreviewStart = useCallback(
(project: ProjectExplorerItem, anchor?: DOMRect) => {
if (!showHoverPreview) return;
setActiveProject(project);
if (anchor) {
const { width, height } = previewSize(project);
const flip = anchor.right > window.innerWidth - width - 48;
springX.set(
flip ? anchor.left - width - 16 : anchor.right + 16,
);
springY.set(
Math.max(
16,
Math.min(anchor.top, window.innerHeight - height - 24),
),
);
}
},
[springX, springY, showHoverPreview],
);
const [holding, setHolding] = useState(false);
// A held row on a touch screen: centre the card and put it above the row,
// or below it when there is no room, so the finger never covers it.
const handleHoldStart = useCallback(
(project: ProjectExplorerItem, anchor: DOMRect) => {
if (!showHoverPreview) return;
const size = previewSize(project);
const width = Math.min(size.width, window.innerWidth - 32);
const above = anchor.top - size.height - 12;
springX.jump(Math.max(16, (window.innerWidth - width) / 2));
springY.jump(
above >= 16
? above
: Math.min(anchor.bottom + 12, window.innerHeight - size.height - 16),
);
setActiveProject(project);
setHolding(true);
},
[springX, springY, showHoverPreview],
);
const handlePreviewEnd = useCallback(() => {
setActiveProject(null);
setHolding(false);
}, []);
const showPreview =
showHoverPreview &&
Boolean(activeProject) &&
(holding || (canHover && !prefersReducedMotion));
const activeTitle = activeProject ? splitTitle(activeProject.title) : null;
const activePortrait = activeProject?.imageOrientation === "portrait";
const hasImage = Boolean(
activeProject?.image && activeProject?.showImage !== false,
);
return (
<section
id={id}
className={cn(showHeading && "border-t border-dashed", "pt-6", className)}
>
{showHeading ? (
<h2 className="no-js-visible section-heading mb-3">
{title}
</h2>
) : null}
<div
className="flex flex-col gap-5 px-4 sm:px-6"
onMouseMove={handleMouseMove}
onMouseLeave={handlePreviewEnd}
>
{projectsByYear.map((group) => {
const isOpen = openYears[group.year] ?? true;
return (
<div key={group.year}>
<button
type="button"
onClick={() => toggleYear(group.year)}
aria-expanded={isOpen}
className="group mb-1 flex min-h-10 cursor-pointer select-none items-center gap-1.5 rounded-sm px-1 py-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/40"
>
<PaperFolderIcon
open={isOpen}
className="-rotate-6 transition-transform duration-200 motion-safe:group-hover:-translate-y-0.5 motion-safe:group-hover:-rotate-12 motion-safe:group-focus-visible:-translate-y-0.5 motion-safe:group-focus-visible:-rotate-12 motion-reduce:rotate-0 motion-reduce:transition-none"
/>
<span className="relative isolate -rotate-2 px-2.5 py-1 font-serif text-xl italic leading-none text-foreground transition-transform duration-200 motion-safe:group-hover:rotate-0 motion-safe:group-focus-visible:rotate-0 motion-reduce:rotate-0 motion-reduce:transition-none">
<span
aria-hidden="true"
className="absolute inset-0 -z-10 bg-muted/70"
style={{ clipPath: PAPER_EDGE }}
/>
<span
aria-hidden="true"
className="absolute inset-0 -z-10 opacity-[0.08]"
style={{ ...PAPER_TEXTURE, clipPath: PAPER_EDGE }}
/>
{group.year}
</span>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.ul
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="ml-2 overflow-hidden"
>
{group.projects.map((project) => (
<WorkRow
key={project.id}
project={project}
onPreviewStart={handlePreviewStart}
onHoldStart={handleHoldStart}
onPreviewEnd={handlePreviewEnd}
showHoverPreview={showHoverPreview}
onProjectClick={onProjectClick}
/>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
);
})}
</div>
<AnimatePresence>
{showPreview && activeProject ? (
<motion.div
key={activeProject.id}
aria-hidden="true"
initial={{ opacity: 0, scale: 0.92, rotate: -2 }}
animate={{ opacity: 1, scale: 1, rotate: 1.5 }}
exit={{ opacity: 0, scale: 0.95, rotate: 0 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.18,
ease: [0.22, 1, 0.36, 1],
}}
style={{ x: springX, y: springY, width: previewSize(activeProject).width }}
className="pointer-events-none fixed left-0 top-0 z-50 max-w-[calc(100vw-32px)] drop-shadow-[2px_4px_6px_var(--border)]"
>
<div
className="relative isolate bg-card p-3 text-card-foreground"
style={{ clipPath: PAPER_EDGE }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.08]"
style={PAPER_TEXTURE}
/>
<div className="relative overflow-hidden bg-muted/40">
{hasImage ? (
<Image
src={activeProject.image!}
alt={activeProject.imageAlt ?? ""}
width={activePortrait ? 400 : 600}
height={activePortrait ? 866 : 338}
sizes={activePortrait ? "176px" : "276px"}
// The card only exists while it is shown, so there is
// nothing to defer: lazy loading here just delays it.
loading="eager"
className={cn(
"w-full object-cover saturate-[0.7]",
activePortrait ? "aspect-[1170/2532] object-top" : "aspect-video",
)}
/>
) : (
<div className="flex aspect-video w-full items-center justify-center text-muted-foreground">
<ImageIcon aria-hidden="true" className="size-8 stroke-[1.25]" />
</div>
)}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.12] mix-blend-multiply dark:mix-blend-screen"
style={PAPER_TEXTURE}
/>
</div>
<div className="flex items-start gap-3 pt-2">
<div className="line-clamp-1 min-w-0 flex-1 font-serif text-lg italic leading-5">
{activeTitle?.name}
</div>
<span className="shrink-0 pt-1 font-mono text-[10px] text-muted-foreground">
{activeProject.year}
</span>
</div>
{activeTitle?.tagline ? (
<p className="line-clamp-2 pt-1 text-xs leading-4 text-muted-foreground">
{activeTitle.tagline}
</p>
) : null}
</div>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
export default ProjectExplorer;