← Components

Folder Hover Tabs

Service folders that lift and fan three photos open on hover.

Cards · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

18 deg
24 px
0.60 s
#f5e04a
200 px
3
Your code
<FolderHover folders={folders} />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";

// Service folders stacked along the bottom of the page. Point at one and its tab lifts, the others
// fade back, and three photos slide out from behind the front and fan open above the tab.
// Mouse: hover. Keyboard: Tab onto a folder (Escape closes it). Touch: tap a folder, tap it again to close.

export type FolderItem = {
  /** The name on the tab, e.g. "Branding". */
  title: string;
  /** The small number or word above the title, e.g. "01". */
  label?: string;
  /** The photos inside, left to right. The first three are used. */
  images: string[];
  /** Alt text for each photo, in the same order. */
  alts?: string[];
  /** This folder's colour. Used unless the `color` prop paints every folder the same. */
  color?: string;
};

export type FolderHoverProps = {
  /** The folders, in reading order. Five fill two rows nicely (2 then 3). */
  folders: FolderItem[];
  /** The widest tilt of the photos when a folder opens, in degrees. The left photo leans left, the right one leans right. */
  fan?: number;
  /** How far the open folder's front and tab rise, in pixels. */
  lift?: number;
  /** How long the folder takes to open, in seconds. */
  duration?: number;
  /** The GSAP ease of the photos sliding out, e.g. "back.out(1.4)" or "power3.out". */
  ease?: string;
  /** The colour used when `colorMode` is "single". */
  color?: string;
  /** "own" gives each folder its own colour, "single" paints them all with `color`. */
  colorMode?: "own" | "single";
  /** The colour of the page behind the folders. The folders you are not pointing at fade toward it. Any CSS colour or var(). */
  paper?: string;
  /** The colour of text on the page. The faded folders print their titles in a soft mix of it. Any CSS colour or var(). */
  ink?: string;
  /** The most folders in one row. Narrow boxes use fewer, so the folders never get too thin. */
  perRow?: number;
  /** The height of one folder in pixels (the tab sits on top of it). Narrow boxes shrink it a quarter. */
  folderHeight?: number;
  /** The index of a folder that starts open, or null for none. It closes as soon as the pointer leaves it. */
  defaultOpen?: number | null;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// Used when a folder has no colour of its own
const FALLBACK_COLOURS = ["#f5e04a", "#d4d4d4", "#e8e4dc", "#9c9c9c", "#ffcc4d"];
// The text printed on a folder. The folders are always light-ish paper, so this stays dark in both themes.
const FOLDER_INK = "#1a1a1a";
// A folder never gets thinner than this, so titles keep fitting
const MIN_FOLDER_WIDTH = 170;

// Where the three photos sit (-1 left, 0 middle, 1 right of the centre of the photo box) and how far
// each may tilt, as a share of `fan`. The middle photo is the only one that can lean either way.
const SLOTS = [
  { side: -1, tilt: [-1, -0.5] },
  { side: 0, tilt: [-0.5, 0.5] },
  { side: 1, tilt: [0.5, 1] },
] as const;

/** Splits the folders into rows, filling the bottom row first: 5 folders at 3 per row become 2 + 3. */
function toRows(count: number, perRow: number): number[][] {
  const rows: number[][] = [];
  for (let end = count; end > 0; end -= perRow) {
    const start = Math.max(0, end - perRow);
    rows.unshift(Array.from({ length: end - start }, (_, i) => start + i));
  }
  return rows;
}

export function FolderHover({
  folders,
  fan = 18,
  lift = 24,
  duration = 0.6,
  ease = "back.out(1.4)",
  color = "#f5e04a",
  colorMode = "own",
  paper = "#ece8e1",
  ink = "#1a1a1a",
  perRow = 3,
  folderHeight = 200,
  defaultOpen = null,
  className = "",
}: FolderHoverProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const pointerType = useRef("mouse");

  // The folder that is open right now. React state, so the colours and aria-expanded follow it.
  const [open, setOpen] = useState<number | null>(defaultOpen);
  // The folder that has keyboard focus (it gets a ring on its front, because the button itself stays put while the front lifts)
  const [focused, setFocused] = useState<number | null>(null);
  // How many folders fit in a row at the current width (measured below)
  const [columns, setColumns] = useState(perRow);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const measure = () => {
      const fit = Math.floor(root.clientWidth / MIN_FOLDER_WIDTH);
      setColumns(Math.max(1, Math.min(perRow, fit)));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [perRow]);

  // Everything is sized from the folder height, so a narrow box just uses a smaller one
  const height = columns >= 3 ? folderHeight : Math.round(folderHeight * 0.75);
  const tabHeight = Math.round(height * 0.24);
  const photoWidth = Math.round(height * 0.6);
  const photoHeight = Math.round(height * 0.88);
  const restTop = Math.round(height * 0.06);
  // Open: the photos end up with about 60% of their height above the lifted tab
  const openTop = -(tabHeight + lift) - photoHeight * 0.62;
  const travel = openTop - restTop;

  // Opens the folder that is `open` and closes the rest. It runs again whenever the open folder or a
  // setting changes, and every folder gets fresh tweens each time (`overwrite: true` removes the old
  // ones, even those still waiting out their delay), so quick hovering from folder to folder never
  // leaves a photo stranded. A plain effect instead of useGSAP: useGSAP would either revert the
  // photos to the start on every change or keep every old tween around.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // With reduced motion the folder still opens, it just does it in one step
    const time = reduced ? 0 : duration;
    // Found by data attributes, so a folder that moved to another row is still found
    const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-panel]"));
    const photos = Array.from(root.querySelectorAll<HTMLElement>("[data-photo]"));

    root.querySelectorAll<HTMLElement>("[data-folder]").forEach((folderElement) => {
      const isOpen = Number(folderElement.dataset.folder) === open;
      const panel = folderElement.querySelector<HTMLElement>("[data-panel]");
      if (panel) {
        gsap.to(panel, { y: isOpen ? -lift : 0, duration: time * (isOpen ? 0.7 : 0.6), ease: "power3.out", overwrite: true });
      }
      folderElement.querySelectorAll<HTMLElement>("[data-photo]").forEach((photo, slot) => {
        const [low, high] = SLOTS[slot]!.tilt;
        gsap.to(photo, {
          y: isOpen ? travel : 0,
          rotation: isOpen ? gsap.utils.random(low * fan, high * fan) : 0,
          duration: isOpen ? time : time * 0.7,
          // Slide out with a little overshoot, tuck back without one
          ease: isOpen ? ease : "power2.inOut",
          // The photos leave in order and return in order
          delay: time * (isOpen ? 0.06 : 0.04) * slot,
          overwrite: true,
        });
        // Closed photos are hidden outright. Their shadows would otherwise leak out of a narrow folder
        // onto its neighbour. Fading is quick and shares the photo's delay, so it never shows mid-rise.
        gsap.to(photo, {
          autoAlpha: isOpen ? 1 : 0,
          duration: time === 0 ? 0 : 0.12,
          delay: isOpen ? time * 0.06 * slot : time * (0.04 * slot + 0.6),
          ease: "none",
          overwrite: "auto",
        });
      });
    });

    return () => {
      gsap.killTweensOf([...panels, ...photos]);
    };
  }, [open, fan, lift, duration, ease, travel, folders.length, columns]);

  const openFolder = (index: number) => setOpen(index);
  const closeFolder = (index: number) => setOpen((current) => (current === index ? null : current));

  const rows = toRows(folders.length, columns);

  return (
    <div
      ref={rootRef}
      // The side gutter leaves room for the photos an edge folder fans out
      className={`relative flex h-full w-full flex-col justify-end overflow-hidden px-7 md:px-12 ${className}`}
      style={{ containerType: "inline-size" }}
      onPointerDown={(event) => {
        pointerType.current = event.pointerType;
      }}
      onClick={(event) => {
        // A tap on the empty page closes the open folder on touch screens
        if (event.target === event.currentTarget && pointerType.current === "touch") setOpen(null);
      }}
    >
      {rows.map((row, rowIndex) => (
        <div key={rowIndex} className="flex w-full">
          {row.map((index) => {
            const folder = folders[index]!;
            const base = colorMode === "single" ? color : (folder.color ?? FALLBACK_COLOURS[index % FALLBACK_COLOURS.length]!);
            const isOpen = open === index;
            // While another folder is open this one fades toward the page colour
            const faded = open !== null && !isOpen;
            const fill = faded ? `color-mix(in srgb, ${base} 18%, ${paper})` : base;
            const textColor = faded ? `color-mix(in srgb, ${ink} 38%, color-mix(in srgb, ${base} 18%, ${paper}))` : FOLDER_INK;
            const slide = `background-color ${Math.max(duration * 0.5, 0.15)}s ease, color ${Math.max(duration * 0.5, 0.15)}s ease`;
            const alts = folder.alts ?? [];

            return (
              <div key={index} data-folder={index} className="relative min-w-0 flex-1" style={{ height }}>
                {/* The photos sit behind the front of the folder until it opens. `isolate` keeps their z-index inside this box, so the front always covers them. */}
                <div aria-hidden={!isOpen} className="pointer-events-none absolute top-0 isolate h-full" style={{ left: 10, width: "min(22rem, calc(100% - 20px))" }}>
                  {SLOTS.map((slot, photoIndex) => {
                    const src = folder.images[photoIndex];
                    if (!src) return null;
                    return (
                      <div
                        key={photoIndex}
                        data-photo={photoIndex}
                        className="invisible absolute overflow-hidden rounded-[3px] border-[3px] border-[#f4f1ea] bg-[#f4f1ea] shadow-[0_14px_28px_-10px_rgba(0,0,0,0.45)]"
                        style={{
                          // 24% of the box to each side, but never so far that a photo sticks out of a narrow folder
                          left: `calc(50% + ${slot.side} * min(24%, 50% - ${photoWidth / 2 + 14}px))`,
                          top: restTop,
                          width: photoWidth,
                          height: photoHeight,
                          marginLeft: -photoWidth / 2,
                          transformOrigin: "50% 100%",
                          // The middle photo sits on top of the other two
                          zIndex: photoIndex === 1 ? 2 : 1,
                          willChange: "transform",
                        }}
                      >
                        <img src={src} alt={alts[photoIndex] ?? ""} className="block h-full w-full object-cover" draggable={false} decoding="async" />
                      </div>
                    );
                  })}
                </div>

                {/* The front of the folder and its tab (the tab overlaps the front by a pixel so no seam shows the photos). It reaches `lift` pixels below the folder so nothing shows underneath when it rises. */}
                <div
                  data-panel=""
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-x-0 top-0"
                  style={{ bottom: -lift, backgroundColor: fill, color: textColor, transition: slide, willChange: "transform", boxShadow: focused === index ? `inset 0 0 0 3px ${FOLDER_INK}` : "none" }}
                >
                  <span
                    className="absolute left-0"
                    style={{ top: -tabHeight, height: tabHeight + 1, width: "min(360px, 100%)", backgroundColor: "inherit", clipPath: "polygon(0 0, 78% 0, 100% 100%, 0 100%)" }}
                  />
                  <span className="absolute text-[11px] font-semibold tracking-[0.06em]" style={{ left: 16, top: -tabHeight * 0.58 }}>
                    {folder.label ?? String(index + 1).padStart(2, "0")}
                  </span>
                  <div className="font-semibold tracking-[-0.01em]" style={{ padding: "12px 16px", fontSize: "clamp(1.25rem, 2.6cqw, 2.25rem)", lineHeight: 1.1 }}>
                    {folder.title}
                  </div>
                </div>

                {/* The real control: it covers the tab and the front, so pointing at the tab counts too */}
                <button
                  type="button"
                  aria-expanded={isOpen}
                  className="absolute right-0 bottom-0 left-0 cursor-pointer border-0 bg-transparent p-0 outline-none"
                  style={{ top: -(tabHeight + lift + 4) }}
                  onPointerEnter={(event) => {
                    if (event.pointerType !== "touch") openFolder(index);
                  }}
                  onPointerLeave={(event) => {
                    if (event.pointerType !== "touch") closeFolder(index);
                  }}
                  onClick={() => {
                    // On touch there is no hover, so a tap opens it and another tap closes it
                    if (pointerType.current === "touch") setOpen((current) => (current === index ? null : index));
                  }}
                  onFocus={(event) => {
                    // Only keyboard focus opens it (a mouse click focuses too, and hover already handles that)
                    if (!event.currentTarget.matches(":focus-visible")) return;
                    setFocused(index);
                    openFolder(index);
                  }}
                  onBlur={() => {
                    setFocused((current) => (current === index ? null : current));
                    closeFolder(index);
                  }}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") closeFolder(index);
                  }}
                >
                  <span className="sr-only">{folder.title}</span>
                </button>
              </div>
            );
          })}
        </div>
      ))}
    </div>
  );
}

The component. Installs to components/dimaac/folder-hover.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/folder-hover.json

It adds components/dimaac/folder-hover.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/folder-hover.

By hand

npm install gsap
  1. Install the packages it uses: gsap.
  2. Copy the component file below into components/dimaac/folder-hover.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { FolderHover } from "@/components/dimaac/folder-hover" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
folders (required)FolderItem[]-The folders, in reading order. Five fill two rows nicely (2 then 3).
fannumber18The widest tilt of the photos when a folder opens, in degrees. The left photo leans left, the right one leans right.
liftnumber24How far the open folder's front and tab rise, in pixels.
durationnumber0.6How long the folder takes to open, in seconds.
easestring"back.out(1.4)"The GSAP ease of the photos sliding out, e.g. "back.out(1.4)" or "power3.out".
colorstring"#f5e04a"The colour used when `colorMode` is "single".
colorMode"own" | "single""own""own" gives each folder its own colour, "single" paints them all with `color`.
paperstring"#ece8e1"The colour of the page behind the folders. The folders you are not pointing at fade toward it. Any CSS colour or var().
inkstring"#1a1a1a"The colour of text on the page. The faded folders print their titles in a soft mix of it. Any CSS colour or var().
perRownumber3The most folders in one row. Narrow boxes use fewer, so the folders never get too thin.
folderHeightnumber200The height of one folder in pixels (the tab sits on top of it). Narrow boxes shrink it a quarter.
defaultOpennumber | nullnullThe index of a folder that starts open, or null for none. It closes as soon as the pointer leaves it.
classNamestring""Extra classes for the outer box. Give it a size (it fills it).

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Folder Hover Tabs component (<FolderHover />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/folder-hover
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/folder-hover.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/folder-hover.json

Then use it like this:
import { FolderHover } from "@/components/dimaac/folder-hover";
<FolderHover folders={folders} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/folder-hover.tsx and install gsap.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
folder-hover.md
This page as Markdown: install, props and the full source
folder-hover.json
The shadcn registry item

About

A stack of coloured folders along the bottom of the page, like a filing drawer. Point at one and its tab lifts, the folders you are not pointing at fade back, and three photos slide out from behind the front and fan open above the tab. Each folder has its own colour, title, number and photos. It works with the mouse, with the keyboard (Tab onto a folder, Escape to close) and with touch (tap to open, tap again to close), it makes fewer columns on narrow screens, and with reduced motion the folders still open, just in one step. Rebuilt from a YouTube demo with scoped GSAP tweens and no Pinterest images.

Installs to
components/dimaac/folder-hover.tsx
Uses
gsap
Tags
gsap, hover, folders, images, services

Photo: Elegant perfume bottle by Dona Mara on Lummi (Lummi License). Photo: Elegant perfume bottles by Amino on Lummi (Lummi License). Photo: Minimalist skincare tube by Clemara Studio on Lummi (Lummi License). Photo: Abstract eye close-up by Kuba on Lummi (Lummi License). Photo: Modern fashion pose by Axel Photography on Lummi (Lummi License). Photo: Serene gradient landscape by Nika on Lummi (Lummi License). Photo: Modern architectural design by rena on Lummi (Lummi License). Photo: Contemplative silhouette against minimalist architecture by Viri Gutiérrez on Lummi (Lummi License). Photo: Modern minimalist building by Jupiter et Passagère® on Lummi (Lummi License). Photo: Vibrant cylindrical arrangement by SHIHO on Lummi (Lummi License). Photo: Mystery portrait by SHIHO on Lummi (Lummi License). Photo: Portrait through orange frame by Bernardo Lickfold on Lummi (Lummi License). Photo: Surreal 3D planet scene by Ricardo Matos on Lummi (Lummi License). Photo: Abstract sculptural objects by Dona Mara on Lummi (Lummi License). Photo: Intricate natural texture by Nika on Lummi (Lummi License).