---
name: nikuma-folder
description: Add pastel Mac-style folder cards with a shape pressed into the front (shop, basketball, book, pencil ...) and cards that fan out on hover. Use when the user asks for folders, a folder view, or category cards in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Folder cards

A grid of soft Mac-style folders. Every folder has a little shape pressed into its front, a count underneath, and three paper cards that peek out of the top and fan open on hover. A folder is either a link (`href`) or a button (`onOpen`).

One dependency-free file. `mountFolder(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.

## When to use

The user wants: folder cards, categories shown as folders, or a "folder view" for products, posts or files. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.

## How to add it

1. Save the file(s) under **Source** into the project (for example `src/lib/folder/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Check it in both light and dark theme.

```js
import { mountFolder } from './folder.js';
const stop = mountFolder(document.getElementById('stage'), { glyph: 'shop', columns: 2, folders: [{ name: 'Products', items: ['Atlas', 'Notes'] }, { name: 'Ideas', glyph: 'pencil', items: ['Garden'] }] });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `folders` | `3 samples` | `[{ name, items?, count?, glyph?, hue?, href? }, ...]` one folder each. `items` = up to three titles shown on the cards |
| `glyph` | `''` | the shape on every folder: `shop`, `basketball`, `book`, `pencil`, `turtle`, `butterfly`, `fish`, `bird`, `leaf`. Empty = a different one on each |
| `columns` | `2` | folders per row (1 to 4) |
| `size` | `230` | the widest a folder may be, in px |
| `hues` | `[28,152,268,46,204,346]` | colours (0 to 360) used one after the other |
| `gray` | `true` | a light gray tile behind every folder |
| `dark` | `false` | always use the dark colours |
| `onOpen` | none | `function(folder, index)` called when a folder without `href` is clicked |

## Rules

- The container is filled by the component (`el.innerHTML`): give it an empty element. It needs no height, the folders size themselves.
- Use one `glyph` per kind of content, and at most about six folders.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. To add a new shape, add its SVG to the `GLYPH` object.

## Source

### `folder.js`

```js
// Pastel Mac-style folder cards. Each folder has a shape pressed into its front (a shop, a basketball, a book ...); three cards peek out of it and fan open on hover.
// mountFolder(el, options) -> stop()      Fills `el`. No dependencies, the styles are added by this file.
//
// options (all optional):
//   folders   [{ name, items?, count?, glyph?, hue?, href? }, ...]   one folder each. `items` = up to three titles shown on the cards that peek out.
//             `href` makes the folder a link; without it a click calls onOpen(folder).                         default: three samples
//   glyph     the shape pressed into every folder: 'shop' 'basketball' 'book' 'pencil' 'turtle' 'butterfly' 'fish' 'bird' 'leaf'
//             ('' = a different one on each folder). A folder's own `glyph` wins.                                 default ''
//   columns   folders per row, 1 to 4                                                                           default 2
//   size      widest a folder may be, in px                                                                     default 230
//   hues      colours (0-360) used one after the other: peach, mint, lavender, butter, sky, rose                 default [28,152,268,46,204,346]
//   gray      true = a light gray tile behind every folder (always visible)                                     default true
//   dark      true = always use the dark colours (normally they follow the page)                                 default false
//   onOpen    function(folder, index) called when a folder without `href` is clicked
//
// Colours: light and dark are picked from the page (`<html data-theme="dark">`, or the visitor's system setting when there is no data-theme).
const DEFAULT = [
  { name: 'Products', glyph: 'shop', items: ['Atlas', 'Notes', 'Orbit'] },
  { name: 'Playground', glyph: 'basketball', items: ['Grass', 'Chimes', 'Haze'] },
  { name: 'Writing', glyph: 'book', items: ['Calm interfaces', 'Weekly review', 'Hello world'] },
];
const KINDS = ['turtle', 'butterfly', 'fish', 'bird', 'leaf'];
const GLYPH = {
  shop: '<path d="M7 23 12 10h40l5 13a6.25 6.25 0 0 1-12.5 0 6.25 6.25 0 0 1-12.5 0 6.25 6.25 0 0 1-12.5 0A6.25 6.25 0 0 1 7 23Z"/><path d="M11 33h42v21a2 2 0 0 1-2 2H13a2 2 0 0 1-2-2Z"/><path class="k" d="M22 10l-2.5 13M32 10v13M42 10l2.5 13"/><rect class="e" x="24" y="40" width="16" height="16" rx="2"/>',
  basketball: '<circle cx="32" cy="32" r="26"/><path class="k" d="M32 6v52M6 32h52M13.6 13.6C21 21 21 43 13.6 50.4M50.4 13.6C43 21 43 43 50.4 50.4"/>',
  book: '<path d="M30.8 17C25 13.2 16 12 8 14v36c8-2 17-1 22.8 3Z"/><path d="M33.2 17C39 13.2 48 12 56 14v36c-8-2-17-1-22.8 3Z"/><path class="k" d="M14 22.5c4-.8 8-.6 12 .8M14 30.5c4-.8 8-.6 12 .8M14 38.5c4-.8 8-.6 12 .8M38 23.3c4-1.4 8-1.6 12-.8M38 31.3c4-1.4 8-1.6 12-.8M38 39.3c4-1.4 8-1.6 12-.8"/>',
  pencil: '<g transform="rotate(-45 32 32)"><path d="M3 32 16 25v14Z"/><rect x="16" y="25" width="34" height="14"/><rect x="50" y="25" width="11" height="14" rx="3.5"/><path class="e" d="M3 32l4.6-2.5v5Z"/><path class="k" d="M50 25v14M22 25v14"/></g>',
  turtle: '<path d="M10 41C10 25 21 17 32 17s22 8 22 24Z"/><ellipse cx="58" cy="34" rx="6" ry="5"/><rect x="14" y="39" width="11" height="11" rx="4.5"/><rect x="38" y="39" width="11" height="11" rx="4.5"/><path d="M10 41 2 45l9-9Z"/><path class="k" d="M32 17v24M21 24l5 17M43 24l-5 17M12 33h40"/>',
  butterfly: '<path d="M12 12C8 3 1 3 2 9c.4 3 5 4 10 3zM12 12C7 13 3 16 5 20c2 2 6-2 7-8zM12 12c4-9 11-9 10-3-.4 3-5 4-10 3zM12 12c5 1 9 4 7 8-2 2-6-2-7-8z"/><rect x="11.1" y="6.5" width="1.8" height="11.5" rx=".9"/><path class="k" d="M12 6.8 9.6 3.8M12 6.8l2.4-3"/>',
  fish: '<path d="M5 32C13 17 35 15 46 32 35 49 13 47 5 32Z"/><path d="M43 32 61 19v26Z"/><path d="M24 20c4-8 11-9 15-3-4 1-9 2-15 3Z"/><circle class="e" cx="17" cy="29" r="2.6"/><path class="k" d="M28 24c4 5 4 11 0 16M34 25c3 4 3 10 0 14"/>',
  bird: '<path d="M8 38C8 27 17 20 28 23c4-9 15-10 19-2l12 4-12 5c0 13-9 22-22 20C16 48 8 44 8 38Z"/><circle class="e" cx="44" cy="22" r="1.9"/><path class="k" d="M18 37c6 7 16 7 23-2"/><path class="k" d="M27 47v10M35 46v11"/>',
  leaf: '<path d="M10 54C8 28 25 11 54 9c2 27-12 46-40 45Z"/><path class="k" d="M12 52C26 38 38 26 48 15M30 36l1-12M24 42l-10-4M37 29l11 1"/>',
};
const VB = { butterfly: '0 0 24 24' };
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

// dark colours, written once and used for the three ways a page can be dark
const DARK = (p) => `${p} .fld-back{background:linear-gradient(hsl(var(--fh) 26% 38%),hsl(var(--fh) 24% 31%))}
${p} .fld-back::before{background:hsl(var(--fh) 26% 38%)}
${p} .fld-card{background:#38363a;box-shadow:0 2px 8px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.06)}
${p} .fld-card u{color:#e2dedb}
${p} .fld-card b{background:hsl(var(--fh) 20% 46%)}
${p} .fld-front{background:linear-gradient(180deg,hsl(var(--fh) 34% 60%) 0,hsl(var(--fh) 30% 50%) 30%,hsl(var(--fh) 28% 42%) 100%);box-shadow:inset 0 2px 0 rgba(255,255,255,.35),inset 0 -3px 0 hsl(var(--fh) 30% 30%/.5),0 14px 22px -8px rgba(0,0,0,.55)}
${p} .fld-glyph{fill:hsl(var(--fh) 30% 38%);--cut:hsl(var(--fh) 32% 52%);filter:drop-shadow(0 1.5px 0 rgba(255,255,255,.3)) drop-shadow(0 -1px 0 rgba(0,0,0,.25))}`;

export function mountFolder(el, o = {}) {
  css('fld-css', `.fld{display:grid;grid-template-columns:repeat(var(--cols,2),minmax(0,1fr));gap:10px;color:inherit;font:inherit}
.fld-f{--fh:28;display:flex;flex-direction:column;align-items:center;padding:30px 10px 14px;border:0;border-radius:22px;text-decoration:none;color:inherit;font:inherit;cursor:pointer;text-align:center;-webkit-appearance:none;appearance:none;background:var(--fld-tile,var(--surface,rgba(127,127,127,.1)))}
.fld-f:not(.gray){background:none}
.fld-d{position:relative;display:block;width:min(var(--size,230px),100%);aspect-ratio:1.24;transition:transform .6s cubic-bezier(.2,.7,.1,1)}
.fld-f:hover .fld-d,.fld-f:focus-visible .fld-d{transform:translateY(-3px)}
.fld-d i{position:absolute;display:block;font-style:normal}
.fld-back{inset:11% 0 0;border-radius:9px 18px 18px 18px;background:linear-gradient(hsl(var(--fh) 62% 76%),hsl(var(--fh) 56% 70%));box-shadow:0 8px 16px -8px hsl(var(--fh) 50% 40%/.35)}
.fld-back::before{content:"";position:absolute;left:0;top:-11px;width:41%;height:18px;border-radius:10px 14px 0 0;background:hsl(var(--fh) 62% 76%)}
.fld-card{left:8%;right:8%;top:15%;bottom:32%;padding:11% 11% 0;border-radius:10px;background:#fffdf9;display:flex;flex-direction:column;gap:6px;transform-origin:50% 100%;box-shadow:0 2px 8px rgba(60,40,20,.12),0 0 0 1px rgba(60,40,20,.04);transition:transform .75s cubic-bezier(.2,.7,.1,1)}
.fld-card u{display:block;text-decoration:none;font:600 10.5px/1.25 system-ui,sans-serif;color:#4a4540;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fld-card b{display:block;height:4px;border-radius:2px;background:hsl(var(--fh) 50% 90%)}.fld-card b.s{width:55%}
.fld-card.c1{transform:translateY(9%) rotate(-1.5deg)}.fld-card.c2{transform:translateY(4%)}.fld-card.c3{transform:translateY(9%) rotate(1.5deg)}
.fld-f:hover .fld-card.c1,.fld-f:focus-visible .fld-card.c1{transform:translate(-26%,-26%) rotate(-13deg)}
.fld-f:hover .fld-card.c2,.fld-f:focus-visible .fld-card.c2{transform:translateY(-38%) rotate(1deg);transition-delay:.04s}
.fld-f:hover .fld-card.c3,.fld-f:focus-visible .fld-card.c3{transform:translate(26%,-24%) rotate(13deg);transition-delay:.08s}
.fld-front{left:0;right:0;bottom:0;height:72%;border-radius:12px 18px 18px 18px;transform-origin:50% 100%;transition:transform .7s cubic-bezier(.2,.7,.1,1);
  background:linear-gradient(180deg,hsl(var(--fh) 90% 93%) 0,hsl(var(--fh) 78% 87%) 30%,hsl(var(--fh) 68% 82%) 100%);
  box-shadow:inset 0 2px 0 rgba(255,255,255,.9),inset 0 -3px 0 hsl(var(--fh) 55% 70%/.45),0 14px 22px -8px hsl(var(--fh) 55% 45%/.4)}
.fld-f:hover .fld-front,.fld-f:focus-visible .fld-front{transform:perspective(520px) rotateX(-10deg)}
.fld-glyph{position:absolute;left:50%;top:55%;width:46%;height:auto;max-height:60%;transform:translate(-50%,-50%);fill:hsl(var(--fh) 52% 68%);--cut:hsl(var(--fh) 62% 84%);
  filter:drop-shadow(0 1.5px 0 rgba(255,255,255,.75)) drop-shadow(0 -1px 0 hsl(var(--fh) 45% 52%/.3))}
.fld-glyph .k{fill:none;stroke:var(--cut);stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.fld-glyph .e{fill:var(--cut)}
.fld-name{margin-top:12px;font-weight:500;font-size:14.5px}
.fld-count{font:400 12px ui-monospace,Menlo,monospace;opacity:.6}
@media(prefers-reduced-motion:reduce){.fld-d,.fld-card,.fld-front{transition-duration:.01s}}
${DARK(':root[data-theme="dark"]')}
${DARK('.fld.fld-dark')}
@media(prefers-color-scheme:dark){${DARK(':root:not([data-theme]) ')}}`);

  const list = o.folders?.length ? o.folders : DEFAULT;
  const hues = o.hues?.length ? o.hues : [28, 152, 268, 46, 204, 346];
  const cols = Math.min(4, Math.max(1, o.columns | 0 || 2));
  const gray = o.gray !== false;
  const open = [];
  el.innerHTML = `<div class="fld${o.dark ? ' fld-dark' : ''}" style="--cols:${cols};--size:${(+o.size || 230)}px">${list.map((f, i) => {
    const g = f.glyph || o.glyph || KINDS[i % KINDS.length], items = (f.items || []).slice(0, 3), n = f.count ?? (f.items || []).length;
    const inner = `<span class="fld-d" aria-hidden="true"><i class="fld-back"></i>${items.map((t, k) => `<i class="fld-card c${k + 1}"><u>${esc(t)}</u><b></b><b class="s"></b></i>`).join('')}<i class="fld-front"><svg class="fld-glyph" viewBox="${VB[g] || '0 0 64 64'}">${GLYPH[g] || GLYPH.leaf}</svg></i></span><span class="fld-name">${esc(f.name)}</span><span class="fld-count">${n} ${n === 1 ? 'item' : 'items'}</span>`;
    const hue = f.hue ?? hues[i % hues.length], cls = `fld-f${gray ? ' gray' : ''}`, st = `--fh:${hue}`;
    return f.href ? `<a class="${cls}" href="${esc(f.href)}" style="${st}">${inner}</a>` : `<button type="button" class="${cls}" data-i="${i}" style="${st}">${inner}</button>`;
  }).join('')}</div>`;
  const root = el.firstElementChild;
  const click = (e) => { const b = e.target.closest('button[data-i]'); if (b && o.onOpen) o.onOpen(list[+b.dataset.i], +b.dataset.i); };
  root.addEventListener('click', click);
  open.push(() => root.removeEventListener('click', click));
  return () => { open.forEach((f) => f()); el.innerHTML = ''; };
}
```

## Framework wrappers

### React: `Folders.jsx`

```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountFolder } from './folder.js';

export default function Folders({ options = {}, className, style }) {
  const ref = useRef(null);
  useEffect(() => mountFolder(ref.current, options), []);   // the engine returns its own cleanup
  return <div ref={ref} className={className} style={style} />;
}
```

### Vue: `Folders.vue`

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountFolder } from './folder.js';

const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountFolder(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>

<template><div ref="root"></div></template>
```

### Svelte: `Folders.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountFolder } from './folder.js';

  export let options = {};
  export let style = '';
  let klass = '';
  export { klass as class };
  let root;
  onMount(() => mountFolder(root, options));   // returning the cleanup from onMount stops it on destroy
</script>

<div bind:this={root} class={klass} {style}></div>
```

### Solid: `Folders.jsx`

```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountFolder } from './folder.js';

export default function Folders(props) {
  let root;
  onMount(() => onCleanup(mountFolder(root, props.options || {})));
  return <div ref={root} class={props.class} style={props.style} />;
}
```
