A frosted folder whose cards rise out of it and fan open when you point at it.
The idea
The hand of cards rises and opens by a degree — paper being lifted and spread, not a block sliding up. The front is a mask rather than a radius, because its top edge slopes and no radius describes that, and it carries no shadow of its own, because a filter on the same element as a backdrop-filter silently kills the blur. The edge the mask cannot draw is a separate half-pixel stroke.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Pocket } from "mihirmodi-ui";
<Pocket
to="/writing"
label="Writing"
caption="12 essays"
cards={posts.slice(0, 4).map((p) => <PostCard key={p.slug} post={p} />)}
/>Props
| Prop | Type | Notes |
|---|---|---|
| label / captionstring | string | Under the folder. The label is also its accessible name. |
| cardsReactNode[] | ReactNode[] | Up to four faces. Only the top third clears the lip at rest, so put what must be legible there. |
| to / href / onClickstring / () => void | string / () => void | Where the folder goes. The whole folder is the target. |
| --folder-glass-bgCSS variable | CSS variable | The front's frost. Solid under reduced transparency. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import type { ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion, type Variants } from "motion/react";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";
const spring = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
const MAX_CARD = 46;
const OVERLAP = 50;
const TILT = 2.5;
const TILT_RISEN = 3.2;
const SPREAD_RISEN = 1.1;
/**
* Card width and step for `n` cards. The spread always fills the pocket: fewer
* files means wider cards, not a short run stranded in the middle.
*/
function geometry(n: number) {
const width = Math.min(MAX_CARD, 200 / (n + 1));
const step = n > 1 ? (100 / width - 1) * (100 / (n - 1)) : OVERLAP;
return { width, step };
}
/** Rest and risen transforms, centred on -50% so the fan opens symmetrically. */
function spread(n: number, step: number): Variants[] {
const open = step * SPREAD_RISEN;
const first = -50 - ((n - 1) * step) / 2;
const firstOpen = -50 - ((n - 1) * open) / 2;
const mid = (n - 1) / 2;
return Array.from({ length: n }, (_, i) => {
const outer = n > 1 && (i === 0 || i === n - 1);
return {
rest: { x: `${first + i * step}%`, y: outer ? "3%" : "0%", rotate: (i - mid) * TILT },
hover: { x: `${firstOpen + i * open}%`, y: outer ? "-25%" : "-28%", rotate: (i - mid) * TILT_RISEN },
};
});
}
const depth = (n: number) => Array.from({ length: n }, (_, i) => 10 + Math.round(((n - 1) / 2 - Math.abs(i - (n - 1) / 2)) * 2));
/* The front's silhouette in the panel's own 202×100 box: the top edge drops
left to right, because a folder's front is cut lower than its back so you can
get at what is inside — and a rectangle says nothing. */
const FRONT_PATH = "M 0,16 Q 0,0 15.96,1.11 L 186.04,12.89 Q 202,14 202,30 L 202,84 Q 202,100 186,100 L 16,100 Q 0,100 0,84 Z";
const FRONT_MASK = `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 202 100" preserveAspectRatio="none"><path d="${FRONT_PATH}" fill="#fff"/></svg>`
)}")`;
export type PocketProps = {
label: string;
caption?: string;
/** Up to four card faces. Only their top third clears the lip at rest, so put what must be legible there. */
cards: ReactNode[];
to?: string;
href?: string;
onClick?: () => void;
link?: LinkComponent;
className?: string;
};
/**
* A frosted folder with cards peeking out of it. Pointed at or focused, the hand
* of cards rises out of the pocket and opens by a degree, like paper being lifted
* and spread rather than a block sliding up.
*
* The front is a mask, not a border radius, because its top edge slopes and no
* radius describes that — and it carries no shadow of its own, because a `filter`
* on the same element as a `backdrop-filter` silently kills the blur. The edge the
* mask cannot draw is a separate half-pixel stroke, and the shadow is a separate
* box under the part of the front that is still rectangular.
*/
export function Pocket({ label, caption, cards, to, href, onClick, link, className = "" }: PocketProps) {
const reduce = useReducedMotion();
const Link = useLinkComponent(link);
const shown = cards.slice(0, 4);
const { width, step } = geometry(shown.length);
const variants = spread(shown.length, step);
const z = depth(shown.length);
const body = (
<>
<m.div
className="relative aspect-[5/4]"
variants={{ rest: {}, hover: {} }}
initial="rest"
animate="rest"
whileHover={reduce ? undefined : "hover"}
whileFocus={reduce ? undefined : "hover"}
>
<div className="absolute inset-y-0 left-2 right-2">
{shown.map((c, i) => (
<m.div
key={i}
className="absolute left-1/2 top-[8%] overflow-hidden rounded-[9px] bg-background-100 shadow-[0_4px_12px_-4px_rgba(0,0,0,0.22)] ring-1 ring-black/[0.06] dark:ring-white/[0.08]"
style={{ width: `${width}%`, aspectRatio: "3 / 4", zIndex: z[i], transformOrigin: "bottom center" }}
variants={variants[i]}
transition={spring}
>
{c}
</m.div>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[53%] rounded-[16px] shadow-[0_10px_28px_-12px_rgba(0,0,0,0.22)] dark:shadow-[0_10px_28px_-12px_rgba(0,0,0,0.55)]" />
<div
className="folder-glass pointer-events-none absolute inset-x-0 bottom-0 z-30 h-[62%]"
style={{ maskImage: FRONT_MASK, WebkitMaskImage: FRONT_MASK, maskSize: "100% 100%", WebkitMaskSize: "100% 100%", maskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat" }}
/>
<svg aria-hidden viewBox="0 0 202 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-x-0 bottom-0 z-30 h-[62%] w-full overflow-visible">
<path d={FRONT_PATH} fill="none" strokeWidth={0.5} vectorEffect="non-scaling-stroke" className="stroke-black/[0.09] dark:stroke-white/[0.13]" />
</svg>
</m.div>
<span className="mt-3 block text-center text-[13px] leading-[1.4] text-gray-1000 transition-opacity duration-150 ease-geist group-hover:opacity-60">{label}</span>
{caption && <span className="mt-1 block text-center text-[12px] leading-[1.45] text-faint">{caption}</span>}
</>
);
const cls = `group relative block text-left transition-[z-index] hover:z-10 focus-within:z-10 ${className}`;
return (
<LazyMotion features={domAnimation} strict>
{to ? (
<Link href={to} aria-label={label} className={cls}>
{body}
</Link>
) : href ? (
<a href={href} aria-label={label} className={cls}>
{body}
</a>
) : (
<button type="button" onClick={onClick} aria-label={label} className={`${cls} w-full`}>
{body}
</button>
)}
</LazyMotion>
);
}