App Download Stack
App Download Stack starts as a collapsed fan of app icons. Clicking expands a selectable grid; you can toggle apps (up to maxPinned-like cap implied by selection), then trigger a download sequence that shows a downloading state with a shine sweep and finally a completion state before auto-resetting. It respects prefers-reduced-motion, supports controlled expanded/selectedApps, and emits lifecycle events.
Usage
Provide apps with id, name, icon (a Nuxt UI icon name). The trigger is a plain button that expands the selector. Use v-model:expanded and v-model:selectedApps to control state, or listen to expand-change, change, download events.
<template>
<div class="flex h-80 w-full items-center justify-center">
<MAppDownloadStack
title="My Apps"
:apps="apps"
/>
</div>
</template>
<script setup lang="ts">
const apps = [
{ id: 1, name: 'Safari', icon: 'i-lucide-compass' },
{ id: 2, name: 'Photos', icon: 'i-lucide-image' },
{ id: 3, name: 'Podcasts', icon: 'i-lucide-mic' },
{ id: 4, name: 'Calendar', icon: 'i-lucide-calendar-days' },
]
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
apps | AppDownloadStackApp[] | undefined | List of apps to render. Defaults to built-in placeholder macOS list if undefined. Each AppDownloadStackApp has id (number), name (string), icon (string, a Nuxt UI icon name such as i-lucide-compass). |
title | string | 'Starter Mac' | Selector header text shown when expanded. |
selectedApps | number[] | undefined | Controlled selection of app ids. Leave undefined for uncontrolled. |
expanded | boolean | undefined | Controlled expansion. Leave undefined for uncontrolled. |
class | string | undefined | Merged onto the outer column wrapper. |
Events
| Event | Payload | Description |
|---|---|---|
change | (selected: number[]) => void | Emitted when selection changes. |
download | (selected: number[]) => void | Emitted when Download Selected is clicked with the current selected ids. |
expand-change | (expanded: boolean) => void | Emitted when expansion toggles. |
update:expanded | (expanded: boolean) => void | v-model update for expansion. |
update:selectedApps | (selected: number[]) => void | v-model update for selection. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | No default slot is used. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.appDownloadStack in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | flex h-auto flex-col items-center justify-center |
wrapper | - | flex flex-col items-center justify-center |
trigger | - | group relative isolate flex h-16 w-16 cursor-pointer items-center justify-center |
stackIcon | - | absolute inset-0 rounded-xl border-none |
selector | - | flex flex-col items-center gap-2 |
selectorHeader | - | flex w-full cursor-pointer items-center justify-between px-0.5 |
title | - | my-0 font-medium leading-none text-highlighted |
countWrap | - | flex items-center gap-1 |
count | - | my-0 font-medium leading-none text-highlighted |
chevron | - | size-4 text-muted |
grid | - | grid grid-cols-2 gap-3 |
tile | - | relative flex h-[80px] w-[80px] |
check | - | pointer-events-none absolute top-2 right-2 z-[1] flex h-4 w-4 items-center justify-center rounded-full border border-solid |
checkmark | - | z-[1] h-3 w-3 |
app | - | group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 border-transparent bg-default/80 p-2 transition-all duration-200 hover:border-primary |
appIcon | - | rounded-lg |
appName | - | font-medium text-highlighted text-xs |
download | - | mt-4 w-full cursor-pointer rounded-lg bg-primary px-4 py-2 font-semibold text-inverted shadow transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50 |
downloading | - | flex flex-col items-center gap-4 |
downloadingWrap | - | relative flex h-16 w-16 items-center justify-center |
shine | - | absolute inset-0 rounded-xl bg-primary/20 |
downloadingIcon | - | absolute inset-0 rounded-xl border-none |
downloadingLabel | - | font-semibold text-primary |
complete | - | flex flex-col items-center gap-4 |
completeLabel | - | font-semibold text-success |
Accessibility
The collapsed trigger is a plain <button> with aria-label="Expand app selection". When expanded, the header is also a button (collapses on click) and each app tile is a button with :aria-pressed="isSelected(app.id)". Checkmark uses a decorative SVG with <title>Checkmark</title>. The download button is disabled when nothing selected. The stack and downloading icons carry role="img" with the app name as the accessible label; the grid tiles are labelled by their visible text. No ARIA live regions announce download state changes; focus is not managed between states. prefers-reduced-motion replaces all motion with near-zero duration variants (floats/rotations become flat). Layout transitions are disabled when reduced motion is preferred (:layout="!shouldReduceMotion").
Performance
- Icon motion is expressed via
variantscallbacks (readingcustomindex) and computed per state; when reduced motion, all variants collapse torest/flat forms. - The shine sweep uses
useAnimationControls().start()called afternextTick()so the target node exists; sweep runs infinitely during download with linear easing and is stopped on completion. - Download sequence uses two
setTimeouts: one forDOWNLOAD_DURATION_MS(3000) to complete, oneRESET_DELAY_MS(1000) to collapse/reset. Timers are cleared on unmount. layoutIdis only applied when motion is not reduced (prevents layout animation noise under reduced motion).- Panel transitions use
AnimatePresencewith scale/opacity; reduced motion uses opacity-only or zero-duration variants. - No WebGL, heavy loops, or per-frame state churn beyond timers and the small computed maps.
apps with Nuxt UI icon names (any i-… Iconify id). The default placeholder icons are Nuxt UI icons, so no external asset request is made.