Skip to content

Folder Stack ​

A folder whose flap tips open as its cards fan out, plus a card pile that deals itself into a grid.

Brand assets5 files

Hover or click the folder.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/folder-stack.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/folder-stack/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useReducedMotion } from 'motion-v'
import { computed, nextTick, ref, useId } from 'vue'
import { cn } from '@/lib/utils'

export interface FolderStackItem {
  id: string | number
  title: string
  subtitle?: string
  image?: string
}

const props = withDefaults(defineProps<{
  items: FolderStackItem[]
  /** Folder / stack name shown underneath. */
  label: string
  /** `folder`: cards fan out of a folder. `stack`: a pile that spreads into a grid. */
  variant?: 'folder' | 'stack'
  /** Open on hover (pointer devices) as well as on click. Defaults to true for `folder`. */
  hover?: boolean
  /** Grid columns for the `stack` variant. */
  columns?: number
  class?: HTMLAttributes['class']
}>(), {
  variant: 'folder',
  hover: undefined,
  columns: 3,
})

const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ select: [item: FolderStackItem] }>()

defineSlots<{
  /** Custom card face. */
  card?: (props: { item: FolderStackItem, index: number }) => any
}>()

const SPRING = { type: 'spring', stiffness: 320, damping: 24 } as const
const FLAP = { type: 'spring', stiffness: 260, damping: 20 } as const
const reduced = useReducedMotion()
const listId = `vx-folder-${useId()}`

const isFolder = computed(() => props.variant === 'folder')
const hoverable = computed(() => props.hover ?? isFolder.value)
const shown = computed(() => (isFolder.value ? props.items.slice(0, 5) : props.items))

// ---------------------------------------------------------------------------
// Poses
// ---------------------------------------------------------------------------
function folderPose(i: number) {
  const n = shown.value.length
  const o = i - (n - 1) / 2
  if (!open.value)
    return { x: o * 5, y: 0, rotate: o * 2.5, scale: 0.94 }
  const spread = Math.min(70, 300 / Math.max(n, 1))
  const lift = reduced.value ? 0 : (Math.abs((n - 1) / 2) - Math.abs(o)) * 10
  return { x: o * spread, y: -96 - lift, rotate: reduced.value ? 0 : o * 9, scale: 1 }
}

const CARD = { w: 156, h: 108, gap: 12 }
const PILE_TILT = [-5, 4, -2, 7, -7, 3, -3, 6]
const rows = computed(() => Math.ceil(shown.value.length / props.columns))
const gridW = computed(() => Math.min(props.columns, shown.value.length) * (CARD.w + CARD.gap) - CARD.gap)
const gridH = computed(() => rows.value * (CARD.h + CARD.gap) - CARD.gap)

function stackPose(i: number) {
  if (!open.value) {
    const depth = Math.min(i, 4)
    return { x: 0, y: depth * -3, rotate: reduced.value ? 0 : PILE_TILT[i % PILE_TILT.length]!, scale: 1 - depth * 0.02 }
  }
  const cols = Math.min(props.columns, shown.value.length)
  const col = i % cols
  const row = Math.floor(i / cols)
  return {
    x: col * (CARD.w + CARD.gap) - (gridW.value - CARD.w) / 2,
    y: row * (CARD.h + CARD.gap) - (gridH.value - CARD.h) / 2,
    rotate: 0,
    scale: 1,
  }
}

function delay(i: number) {
  return open.value ? i * 0.045 : (shown.value.length - 1 - i) * 0.025
}

// ---------------------------------------------------------------------------
// Interaction
// ---------------------------------------------------------------------------
const toggleBtn = ref<HTMLButtonElement>()
const root = ref<HTMLElement>()

function onEnter(e: PointerEvent) {
  if (hoverable.value && e.pointerType === 'mouse')
    open.value = true
}
function onLeave(e: PointerEvent) {
  if (hoverable.value && e.pointerType === 'mouse' && !root.value?.contains(document.activeElement))
    open.value = false
}
function onFocusOut() {
  // Wait a frame: focus may be moving between our own elements.
  requestAnimationFrame(() => {
    if (open.value && !root.value?.contains(document.activeElement))
      open.value = false
  })
}
function onToggle(e: MouseEvent) {
  // With hover enabled the mouse already opened it — a click shouldn't slam it shut.
  if (hoverable.value && (e as PointerEvent).pointerType === 'mouse')
    open.value = true
  else
    open.value = !open.value
}
async function spread() {
  open.value = true
  await nextTick()
  root.value?.querySelector<HTMLElement>('li button')?.focus()
}
async function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && open.value) {
    open.value = false
    await nextTick()
    toggleBtn.value?.focus()
  }
}
function pick(item: FolderStackItem) {
  emit('select', item)
}
</script>

<template>
  <div
    ref="root"
    :class="cn('relative inline-flex flex-col items-center gap-4', props.class)"
    @pointerenter="onEnter"
    @pointerleave="onLeave"
    @focusout="onFocusOut"
    @keydown="onKeydown"
  >
    <!-- ───────────── Folder ───────────── -->
    <div v-if="isFolder" class="relative mt-32 h-44 w-64 [perspective:700px]">
      <!-- back panel + tab -->
      <div aria-hidden="true" class="absolute inset-0 rounded-2xl rounded-tl-none bg-brand/70 shadow-sm">
        <span class="absolute -top-4 left-0 h-5 w-24 rounded-t-xl bg-brand/70" />
        <span class="absolute inset-x-3 top-3 h-px bg-background/30" />
      </div>

      <ul :id="listId" class="absolute inset-x-0 top-0 m-0 list-none p-0" :aria-label="`${props.label} contents`">
        <motion.li
          v-for="(item, i) in shown"
          :key="item.id"
          class="absolute -top-3 left-1/2 -ml-14 origin-bottom"
          :style="{ zIndex: 10 - Math.round(Math.abs(i - (shown.length - 1) / 2) * 2) }"
          :initial="false"
          :animate="folderPose(i)"
          :transition="{ ...SPRING, delay: delay(i) }"
        >
          <motion.button
            type="button"
            :tabindex="open ? 0 : -1"
            :aria-hidden="!open || undefined"
            class="block w-28 overflow-hidden rounded-xl border bg-card text-left text-card-foreground shadow-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            :while-hover="open && !reduced ? { y: -8, scale: 1.05 } : undefined"
            :while-press="open ? { scale: 0.97 } : undefined"
            :transition="SPRING"
            @click="pick(item)"
          >
            <slot name="card" :item="item" :index="i">
              <img v-if="item.image" :src="item.image" alt="" class="block h-24 w-full bg-muted object-cover" draggable="false">
              <span v-else class="block h-24 w-full bg-muted" />
              <span class="block px-2 py-1.5">
                <span class="block truncate text-xs font-medium">{{ item.title }}</span>
                <span v-if="item.subtitle" class="block truncate text-[10px] text-muted-foreground">{{ item.subtitle }}</span>
              </span>
            </slot>
          </motion.button>
        </motion.li>
      </ul>

      <!-- front flap -->
      <motion.div
        aria-hidden="true"
        class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[86%] origin-bottom rounded-2xl border border-background/20 bg-brand shadow-lg"
        :style="{ backgroundImage: 'linear-gradient(to bottom, color-mix(in oklab, var(--background) 22%, transparent), transparent 60%)' }"
        :initial="false"
        :animate="open && !reduced ? { rotateX: -42, y: 2 } : { rotateX: 0, y: 0 }"
        :transition="FLAP"
      >
        <span class="absolute bottom-4 left-4 flex items-center gap-1.5 text-background/90">
          <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></svg>
          <span class="text-xs font-semibold tabular-nums">{{ props.items.length }}</span>
        </span>
      </motion.div>
      <!-- the toggle sits over the flap -->
      <button
        ref="toggleBtn"
        type="button"
        :aria-expanded="open"
        :aria-controls="listId"
        :aria-label="`${props.label}, ${props.items.length} items`"
        class="absolute inset-x-0 bottom-0 z-20 h-[86%] cursor-pointer rounded-2xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        @click="onToggle"
      />
    </div>

    <!-- ───────────── Stack ───────────── -->
    <div
      v-else
      class="relative"
      :style="{ width: `${gridW}px`, height: `${gridH}px` }"
    >
      <ul :id="listId" class="m-0 list-none p-0" :aria-label="`${props.label} projects`">
        <motion.li
          v-for="(item, i) in shown"
          :key="item.id"
          class="absolute left-1/2 top-1/2"
          :style="{ zIndex: shown.length - i, width: `${CARD.w}px`, height: `${CARD.h}px`, marginLeft: `${-CARD.w / 2}px`, marginTop: `${-CARD.h / 2}px` }"
          :initial="false"
          :animate="stackPose(i)"
          :transition="{ ...SPRING, delay: delay(i) }"
        >
          <motion.button
            type="button"
            :tabindex="open ? 0 : -1"
            :aria-hidden="!open || undefined"
            class="relative block size-full overflow-hidden rounded-xl border bg-card text-left text-card-foreground shadow-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            :while-hover="open && !reduced ? { y: -4, scale: 1.03 } : undefined"
            :while-press="open ? { scale: 0.97 } : undefined"
            :transition="SPRING"
            @click="pick(item)"
          >
            <slot name="card" :item="item" :index="i">
              <img v-if="item.image" :src="item.image" alt="" class="absolute inset-0 size-full bg-muted object-cover" draggable="false">
              <span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-background/90 via-background/60 to-transparent px-2.5 pb-2 pt-5">
                <span class="block truncate text-xs font-semibold">{{ item.title }}</span>
                <span v-if="item.subtitle" class="block truncate text-[10px] text-muted-foreground">{{ item.subtitle }}</span>
              </span>
            </slot>
          </motion.button>
        </motion.li>
      </ul>
      <button
        v-if="!open"
        ref="toggleBtn"
        type="button"
        :aria-expanded="false"
        :aria-controls="listId"
        :aria-label="`${props.label}: spread ${props.items.length} projects`"
        class="absolute left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        :style="{ width: `${CARD.w + 16}px`, height: `${CARD.h + 16}px` }"
        @click="spread"
      />
    </div>

    <div class="flex items-center gap-2 text-sm">
      <span class="font-medium">{{ props.label }}</span>
      <span class="text-muted-foreground tabular-nums">{{ props.items.length }} {{ isFolder ? 'files' : 'projects' }}</span>
      <button
        v-if="!isFolder && open"
        ref="toggleBtn"
        type="button"
        :aria-expanded="true"
        :aria-controls="listId"
        class="rounded-md px-1.5 py-0.5 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
        @click="open = false"
      >
        Stack
      </button>
    </div>
  </div>
</template>
ts
export { default as FolderStack } from './FolderStack.vue'
export type { FolderStackItem } from './FolderStack.vue'

Usage ​

ts
import { FolderStack, type FolderStackItem } from '@/components/ui/folder-stack'
vue
<!-- Fans up to five cards out of a folder on hover / click -->
<FolderStack :items="files" label="Brand assets" @select="openFile" />

<!-- A pile of cards that spreads into a grid -->
<FolderStack :items="projects" label="Projects" variant="stack" :columns="3" @select="openProject">
  <template #card="{ item }">
    <ProjectThumbnail :project="item" />
  </template>
</FolderStack>

The folder flap (or the pile) is a real toggle button with aria-expanded; cards only become focusable once they're out. Escape closes and returns focus to the toggle, and moving focus out of the block closes it too. Hover-to-open only reacts to a mouse, so touch users tap instead.

API ​

PropTypeDefaultDescription
itemsFolderStackItem[]—Cards. The folder shows the first five.
labelstring—Name shown underneath and used for accessible labels.
variant'folder' | 'stack''folder'Folder that fans out, or a pile that spreads into a grid.
hoverbooleantrue for folderAlso open on mouse hover.
columnsnumber3Grid columns for stack.
v-model:openbooleanfalseOpen state.

FolderStackItem: { id: string \| number, title: string, subtitle?: string, image?: string }.

EventPayloadDescription
select(item)A card was clicked.
SlotPropsDescription
card{ item, index }Custom card face.

Designed by Kuldeep Pisda · Released under the MIT License