Drive
- beach.jpg
- demo.mov
- launch.mp4
- podcast.mp3
- portrait.jpg
- sunset.png
- theme.wav
Try it
The numbered controls work. The greyed ones fill out the page and do nothing.
- Switch the layout between grid, list and columns. Every tile glides to its new place and size, and its icon and name slide with it.
- Filter by kind. The files that don't match shrink away and the rest close the gap.
- Sort by name, type or size. The tiles move to their new order.
- Click a tile to expand it, and click again to shrink it. A tile dragged anywhere but the trash springs back to its slot.
- Drag a tile onto Trash. When the pointer reaches the bin, the tile snaps onto it and resizes to fill it. It fades out when you let go. The badge counts up and the storage bar counts down.
- Upload adds a file, which pops in at its sorted place. The button turns off at eight files.
- Undo in the notice puts the last trashed file back.
Reset in the title bar puts everything back to the start.
Built with
<AnimeTransitionGroup>: the tiles, as they enter, leave and move<AnimeLayoutGroup>: the sliding highlight in each segmented control<AnimeTransition>: a tile dropped in the trash, the trash badge and the undo noticeuseDraggable: dragging a tile and springing it backuseAnimatable: the tile filling the trash and the storage barspringfrom#nanime/easings, andstaggerandroundfrom#nanime/utils
State. One composable, useDrive, holds the files, the trash, the
layout, the filter, the sort order and the expanded tile. The window is
split into a title bar, a sidebar, a toolbar, a filter bar, the file grid
and a toast, and each part only gets the props and events it needs.
Tiles. The grid is an <AnimeTransitionGroup>
whose classes change with the layout: grid, flex flex-col or
columns-3. The group records every element inside it before an update and
animates the difference after it, so a layout switch is only a change of
classes. The icon and name move too, because they are elements inside the
group. Filtering and sorting come from one computed that returns a
filtered, sorted copy of the files. The group sees a new order and moves the
tiles into it. An expanded tile gets col-span-2 row-span-2 in the grid and
a bigger height in the other layouts, and its neighbours move out of the way.
New tiles pop in: they scale up from 0.9 on a spring() with a small
bounce, so they overshoot a little and settle. Removed tiles fade and shrink
to 0.95 in 150ms. Moves have no overshoot. The tiles are already on screen,
so they ease out over 300ms with stagger(8) as their delay, and each
tile's content trails the tile by a few milliseconds.
Segmented controls. The pill behind the current option is one element in
the same grid row as the buttons. Its grid-column follows the selected
value. An <AnimeLayoutGroup> with
:deps="[selected]" and elements=".segment-pill" slides it to the new
column and resizes it to the new button's width. Only the pill is listed in
elements, so the buttons stay where they are.
Dragging to the trash. Each tile's handle gets
useDraggable with snap: [0] and a
low-bounce spring() as releaseEase, so a tile dropped anywhere else
returns to its slot.
The hit test follows the pointer, not the tile. A list row is as wide as
the grid, so its centre would never reach the sidebar. The handle's own
pointerdown stores where it was grabbed, and onUpdate adds the
draggable's x and y to that point and tests it against the trash rect.
onGrab measures the trash, the tile's size and its rest centre once, since
nothing else moves during a drag.
The fill is one number from 0 to 1, run by
useAnimatable over 250ms. The card's
width, height and offset are interpolated from it. At 0 the card has its
own size. At 1 it covers the trash, less 6px of padding, and sits on its
centre, whether the tile started as a grid square or a full-width row. An
animatable starts from its current value, so a tile that crosses in and out
of the trash quickly never jumps.
On release over the trash, onRelease calls draggable.stop() to cancel
the return spring and hides the handle. The handle sits in an
<AnimeTransition>, so it fades out first.
The file leaves the list only in @after-leave. Removing it earlier would
unmount the tile, and useDraggable reverts its draggable on unmount,
which moves the card back to its slot before it fades.
The tiles read the trash element through a getter instead of a prop value.
The element only exists after mount, and a prop that changed from null to
the element would re-render the list and run a layout pass over every tile.
Sidebar and toast. The trash badge is keyed on the count inside an
<AnimeTransition mode="out-in">, so every new count pops in.
The storage bar and its number read from one object animated by
useAnimatable, which retargets from its current value when the total
changes again mid-animation. The undo notice is keyed on the file id, so
trashing a second file swaps the notice instead of stacking a new one.
Highlights. The numbered badges are plain CSS: each live control has a
data-live attribute, and a ::after rule prints it with attr().
Using the nanime Nuxt module (AnimeJS v4 for Vue, with Tailwind CSS v4 and
Nuxt UI), build a small file manager in Vue 3 single-file components.
<AnimeTransition>, <AnimeTransitionGroup>, <AnimeLayoutGroup>,
useDraggable and useAnimatable are auto-imported by nanime. Import spring
from '#nanime/easings', and stagger and round from '#nanime/utils'. Use
strict TypeScript with no `any` and no `as` casts. Ease names are AnimeJS
v4 strings such as 'out(3)', not 'easeOutCubic'. A spring sets its own
duration, so spring params have none.
Data: files { id, name, kind: 'image' | 'video' | 'audio', size (MB),
modified (a short date such as 'Sep 12') }, starting with seven files. Put the state in a useDrive() composable: files,
trash (a list of { file, index }), layout ('grid' | 'list' | 'columns'),
filter ('all' or a kind), sort ('name' | 'kind' | 'size'), expandedId and
toast. A computed visibleFiles filters by kind and sorts a copy. Actions:
toggleExpanded, moveToTrash (splice the file out, remember its index, show
the toast for 4 seconds), undo (splice it back at its index), upload (add a
file with a new id and modified 'Just now', up to eight files) and reset.
Layout: a window with a title bar, a sidebar and a main area, in a
fixed-height frame (h-[44rem]) so the page never jumps. Eight list rows
fit inside it, so the file area never scrolls: a scrolling area would
clip a tile dragged out of it.
- Title bar: a Reset button and a "Show interactive" switch.
- Sidebar: Library (current), Recent, Starred and Shared as disabled
buttons, a Trash drop target with a count badge, and a storage bar.
- Toolbar: a disabled search input, a layout switcher, Upload, and a
disabled avatar.
- Filter bar: a kind filter and a sort switcher.
- File grid and an undo toast pinned to the bottom of the main area.
Give every working control data-live="<n>" and draw the number with a CSS
::after { content: attr(data-live) } plus a dashed outline while "Show
interactive" is on.
Segmented control component (layout, filter and sort all use it): an
<AnimeLayoutGroup :deps="[selected]" elements=".segment-pill"
:layout-options="{ duration: 250, ease: 'out(3)' }"> with
classes "relative grid grid-flow-col auto-cols-max". Inside it, a pill
<span class="segment-pill row-start-1"> whose style gridColumn is the
selected index + 1, then one button per option with class "relative
row-start-1" and gridColumn index + 1. The group slides the pill to the new
column. Do not animate the pill yourself.
File grid: <AnimeTransitionGroup tag="ul" class="relative"> whose classes
come from the layout:
grid: 'grid grid-cols-5 auto-rows-[6.5rem] grid-flow-dense gap-3'
list: 'flex flex-col gap-1.5'
columns: 'columns-4 gap-3'
:enter-animation = { opacity: [0, 1], scale: [0.9, 1],
ease: spring({ bounce: 0.15, duration: 350 }) }
:leave-animation = { opacity: 0, scale: 0.95, duration: 150, ease: 'out(2)' }
:move-animation = { duration: 300, delay: stagger(8), ease: 'out(3)' }
The enter is a pop in with a slight overshoot. Moves have no overshoot,
because the tiles are already on screen.
Define these objects once in <script setup>. The group animates layout
changes on its own, including a change of its own classes, so add no CSS
transitions.
Tile component, one per visible file, keyed by id. Root: an <li
class="relative"> sized by layout (grid: expanded gets 'col-span-2
row-span-2'; list: 'h-14 w-full', expanded 'h-20 w-full'; columns: 'mb-3
w-full break-inside-avoid' with a height from ['h-20', 'h-28',
'h-24'][id % 3], expanded 'h-44'). Inside it, an <AnimeTransition
:leave-animation="{ opacity: 0, duration: 150, ease: 'out(2)' }"
@after-leave="emit('trashed')"> around a handle div (ref "handle",
v-if="!dropped", "absolute inset-0 cursor-grab touch-none select-none",
@pointerdown stores the grab point, @click emits toggle). Inside the
handle, the visible card: "absolute inset-0" with the icon, the name, a
second line with the kind and modified date, and the size, and :style
from the fill below. The second line and the size show in list rows and
on the expanded tile. Hide them with a class instead of v-if, so the
group can animate them in and out. The draggable owns the handle's transform,
so the card's own size and offset go on this inner div.
Tile logic:
- The tile gets the trash element through a getter prop, () =>
HTMLElement | null, not the element itself. The element only exists
after mount, and a prop change would re-render the list and run a layout
pass over every tile.
- draggable = useDraggable(handle, { snap: [0], releaseEase: spring({
bounce: 0.1, duration: 400 }), onGrab, onUpdate, onRelease, onSettle }).
- pointerdown on the handle: store the rest pointer as event.clientX/Y
minus draggable.x/y. Test the pointer, not the tile's centre, against the
trash: a list row is as wide as the grid, so its centre never reaches the
sidebar.
- onGrab: read the trash rect and the handle rect. Store the handle's rest
centre (its centre minus draggable.x/y), its width and height, and the
trash's width and height minus 12 (6px padding per side). Set lifted =
true, which adds z-20 to the li so the tile draws over the sidebar.
- onUpdate: the pointer is the rest pointer + draggable.x/y. Over the
trash when it is inside the stored rect. While over, store the vector
from the card's current centre (rest centre + draggable.x/y) to the
trash centre. When the boolean changes, call
fillTo.progress?.(over ? 1 : 0) on useAnimatable(fill, { progress: {
duration: 250, ease: 'out(3)' } }), where fill =
reactive({ progress: 0 }), and emit over so the sidebar can highlight
the trash.
- The card's style, a computed: {} when progress is 0. Otherwise width =
cardWidth + (trashWidth - cardWidth) * progress, the same for height,
x = vector.x * progress - (width - cardWidth) / 2 and y = vector.y *
progress - (height - cardHeight) / 2. Return width and height in px and
transform: translate(xpx, ypx).
- onRelease: if over the trash, call draggable.stop(), emit over(false) and
set dropped = true. onSettle sets lifted = false.
- The parent removes the file only on trashed, after the leave. Removing it
earlier unmounts the tile, and useDraggable reverts its draggable on
unmount, which moves the card back to its slot before it fades.
Sidebar: the badge is a span keyed on the trash count inside <AnimeTransition
mode="out-in" :enter-animation="{ scale: [0.6, 1], ease: spring({ bounce:
0.2, duration: 300 }) }" :leave-animation="{ opacity: 0, duration: 60 }">.
The storage bar reads meter = reactive({ used }) animated by
useAnimatable(meter, { used: { duration: 600, ease: 'out(3)' } }); watch
the total and call meterTo.used?.(total). Show round(meter.used, 0) MB.
Toast: <AnimeTransition mode="out-in"> keyed on the trashed file's id, with
enter { opacity: [0, 1], y: [8, 0], duration: 250, ease: 'out(3)' } and
leave { opacity: 0, y: 4, duration: 150, ease: 'out(2)' }, and an Undo
button.