← All components

Pocket

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Pocket — usage8 lines
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

PropNotes
label / captionstringUnder the folder. The label is also its accessible name.
cardsReactNode[]Up to four faces. Only the top third clears the lip at rest, so put what must be legible there.
to / href / onClickstring / () => voidWhere the folder goes. The whole folder is the target.
--folder-glass-bgCSS variableThe 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.

packages/ui/src/components/media/Pocket.tsx135 lines
"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>
  );
}