Microinteractions, motion, transitions and feedback for UXLab's Nuxt 4 + Nuxt UI v4 + Tailwind v4 stack. Use when adding hover/press/focus feedback, loading and skeleton states, toasts, collapse or list transitions, page transitions, animated numbers, or swipe/drag gestures.
At a glance
- Source: custom
- Category: design-ux
- Scope: project
- User-invocable: no
Location
.claude/skills/interaction-design/
Auto-generated by
scripts/sync-skills-content.ts. Add your own notes below — re-runs preserve the body and your soft frontmatter fields.
Modifications
Adapted on 2026-09-22 from a generic interaction-design skill written for React
and Framer Motion (pasted in; the upstream source was not recorded). What changed:
- No motion library. Framer Motion is replaced by what the stack already has, in
order of preference: Nuxt UI components, Tailwind
motion-safe:utilities, Vue<Transition>/<TransitionGroup>, VueUse (useTransition,useSwipe), and the View Transitions API thatexperimental.viewTransitionalready turns on. - Components over hand-rolled controls.
USwitch,USkeleton,UProgress,UButton loading-autoanduseToast()stand in for the custom toggle, skeleton, progress bar and spinner. - Reduced motion, three ways. The project's own idioms —
motion-safe:in the template, a media query in<style>,disabled: prefersReducedMotionin script — replace the global!importantreset and thewindow.matchMediaread. - House rules. Theme variables instead of
bg-blue-600, the theme radius,resolveIcon, the template → style → script block order. - Cut. The ripple effect and the
AnimatePresencepage transition, which would have stacked on the View Transitions navigation. - Added. The
tw-animate-cssclasses (animate-in,fade-in) are not in the stack: installed but never imported, then removed rather than wired up, since its keyframes replace Nuxt UI's. Reach Nuxt UI's keyframes withanimate-[…].
SKILL.md
.claude/skills/interaction-design/SKILL.md
---
name: interaction-design
description: Microinteractions, motion, transitions and feedback for UXLab's Nuxt 4 + Nuxt UI v4 + Tailwind v4 stack. Use when adding hover/press/focus feedback, loading and skeleton states, toasts, collapse or list transitions, page transitions, animated numbers, or swipe/drag gestures.
---
# Interaction Design
Motion here communicates; it does not decorate. Each animation answers one of four questions: did my action land (**feedback**), where did this come from or go (**orientation**), what changed (**focus**), am I still in the same place (**continuity**). An animation that answers none of them is cut.
The stack has no motion library — no Framer Motion, no `@vueuse/motion`, no GSAP — and needs none. Reach, in this order, for:
1. **A Nuxt UI component** that already owns the interaction (its motion is themed and reduced-motion-safe).
2. **Tailwind utilities** behind the `motion-safe:` variant.
3. **Vue `<Transition>` / `<TransitionGroup>`** with a scoped `<style>` block for enter/leave.
4. **VueUse** (`useTransition`, `useSwipe`, `useMediaQuery`) for values and gestures.
5. **The View Transitions API**, already on for navigation (`experimental.viewTransition` in `nuxt.config.ts`).
Adding a motion dependency is a decision for the owner, not for this skill.
## Timing and easing
| Duration | Use | Tailwind |
| --- | --- | --- |
| 100–150ms | Hover, press, color change | `duration-150` |
| 200–300ms | Toggle, dropdown, collapse | `duration-200` / `duration-300` |
| 300–500ms | Modal, page change, animated number | `duration-300`, `useTransition({ duration: 500 })` |
Enter with `ease-out`, exit with `ease-in`, move between states with `ease-in-out` — Tailwind v4's built-in `--ease-*` tokens. There is no project timing scale in `main.css` yet; if a component needs a value the defaults lack, add it to the `@theme` block there once rather than writing `cubic-bezier(...)` inline in a component. A named animation Nuxt UI lacks (an Animate.css-style `wiggle` or `bounce-in`) goes there too, as `--animate-<name>` plus its `@keyframes` inside `@theme`, which yields `motion-safe:animate-<name>`. Pick a name Nuxt UI's `keyframes.css` does not already use. Animate `transform` and `opacity`; name the properties (`transition-[background-color,transform]`) rather than `transition-all`.
## Reduced motion
Every animation respects `prefers-reduced-motion`. The project spells it three ways, one per layer:
- **Template**: prefix the utility — `motion-safe:transition-transform motion-safe:hover:scale-[1.02]`. The color change can stay unprefixed; movement cannot.
- **`<style>` block**: a `@media (prefers-reduced-motion: reduce)` rule that sets `transition: none` on the same selectors (`app/components/collapsible-insights.vue` is the worked example).
- **Script**: `const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')`, passed as `disabled` to `useTransition` (`app/pages/scoreboard/index.vue`).
Reduced motion removes movement, never the state change: the collapsed panel still collapses, the number still updates.
## Stack rules that bite in motion code
These come from `CLAUDE.md`; they are listed because motion code is where they are most often broken.
- **Colors are theme variables.** `bg-primary`, `bg-elevated`, `text-muted`, `border-default` — never `bg-blue-600` or `rgba(...)`. A hover shadow uses a theme color (`shadow-lg shadow-primary/10`), not a hardcoded black. Check the result in all nine themes, light and dark.
- **Radius comes from the theme.** Use `rounded-(--ui-radius)` or a Nuxt UI component; seven of nine themes round corners and Broadsheet does not.
- **Icons go through `resolveIcon`.** A loading spinner or chevron is `:name="resolveIcon('chevronDown')"`, never `i-tabler-*`.
- **Block order** is `<template>` → `<style>` → `<script setup lang="ts">`. A `<style>` block for enter/leave classes is expected; it should be the only styling there.
- **Collections take `items`**, and `UModal` uses `v-model:open` with `#body` / `#footer` — verify any component API against `node_modules/@nuxt/ui/dist/runtime/components/`.
- **`animate-in` / `fade-in` / `slide-in-from-*` are not part of this stack.** Those are `tw-animate-css` utilities; it was removed rather than imported, because its `accordion-*` and `collapsible-*` keyframes are emitted after Nuxt UI's and replace them app-wide (dropping their `overflow: hidden`). The classes compile to nothing. Nuxt UI's own keyframes are registered (`node_modules/@nuxt/ui/dist/runtime/keyframes.css`: `fade-in`, `scale-in`, `slide-in-from-top-and-fade`, `collapsible-down`, `shimmer`, …) and are reached with an arbitrary value: `motion-safe:animate-[slide-in-from-top-and-fade_200ms_ease-out]`.
## Patterns
### Press and hover feedback
Buttons are `UButton`; add movement only with `motion-safe:`.
```vue
<template>
<UButton
label="Save"
class="motion-safe:transition-transform motion-safe:hover:scale-[1.02] motion-safe:active:scale-[0.98]"
loading-auto
@click="save"
/>
</template>
```
`loading-auto` shows the loading icon while the `@click` promise is pending — no hand-written `loading` ref.
A clickable card lifts by translation, not by growing:
```vue
<template>
<NuxtLink
:to="item.path"
class="group block border border-default bg-elevated p-4 transition-[background-color,transform] duration-150 hover:bg-muted motion-safe:hover:translate-x-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<span class="font-medium text-highlighted group-hover:text-primary transition-colors">
{{ item.title }}
</span>
</NuxtLink>
</template>
```
Focus gets the same visible treatment as hover; a hover-only affordance is a keyboard bug.
### Loading states
- **Skeleton**: `USkeleton` (its theme is `animate-pulse rounded-md bg-elevated`, so it follows the theme). Match the loaded layout's dimensions so nothing shifts on arrival.
```vue
<template>
<div v-if="status === 'pending'" class="space-y-2" aria-busy="true">
<USkeleton class="h-48 w-full" />
<USkeleton class="h-4 w-3/4" />
<USkeleton class="h-4 w-1/2" />
</div>
</template>
```
- **Determinate progress**: `UProgress v-model="percent"`; omit the value for indeterminate, whose animation is already `motion-safe:`.
- **Button pending**: `loading-auto` (above).
### State transitions
A toggle is `USwitch` (`v-model`, `loading`, `label`), not a hand-built `role="switch"` button. Disclosure is `UCollapsible` or `UAccordion` (`items`), which animate height with Nuxt UI's `collapsible-down` / `accordion-down` keyframes.
For a bespoke collapse, use `<Transition>` with scoped classes:
```vue
<template>
<Transition name="collapse">
<div v-if="open" class="overflow-hidden">
<slot />
</div>
</Transition>
</template>
<style scoped>
.collapse-enter-active,
.collapse-leave-active {
transition: opacity 0.2s ease, max-height 0.2s ease;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
}
.collapse-enter-to,
.collapse-leave-from {
max-height: 40rem;
}
@media (prefers-reduced-motion: reduce) {
.collapse-enter-active,
.collapse-leave-active {
transition: none;
}
}
</style>
```
Reordering lists use `<TransitionGroup>` with a `-move` class on `transform` (`app/components/scoreboard/scoreboard-presentation-grid.vue`).
### Animated values
Tween a number with VueUse, disabled under reduced motion:
```ts
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const score = ref(0)
const animatedScore = useTransition(score, {
duration: 500,
transition: TransitionPresets.easeOutCubic,
disabled: prefersReducedMotion,
})
```
Render `Math.round(animatedScore)`; announce the final value, not every frame (`aria-live="polite"` on the settled number only).
### Page transitions
Navigation already runs through the View Transitions API. Style it with `::view-transition-old(root)` / `::view-transition-new(root)` in CSS, or name a shared element with `view-transition-name` so it morphs between routes — a unique name per page, or the transition is skipped. `app/plugins/view-transition-interrupt.client.ts` swallows the errors an interrupted transition throws (#420); leave it alone. Nuxt's `pageTransition` stacks a second animation on top — do not add it.
### Feedback: toasts
`useToast().add({ title, description, icon: resolveIcon('check'), color: 'success', duration })`. A toast confirms something the user cannot otherwise see; an action whose result is on screen needs no toast. Pair a destructive action's toast with an undo action rather than a confirm dialog. Log the interaction, if it is worth counting, with `useAnalytics().track('area:action')` from the catalog in `app/utils/analytics-events.ts`.
### Gestures
Swipe and drag come from VueUse: `useSwipe(target, { onSwipeEnd })` with a distance threshold (~100px) before dismissing, or `useDraggable`. Every gesture has a button or key that does the same thing; swipe is the shortcut, never the only path.
## Before calling it done
- Every animation answers feedback, orientation, focus or continuity.
- Movement is behind `motion-safe:`, a reduced-motion media query, or `disabled: prefersReducedMotion`, and the state still changes with motion off.
- No hardcoded color, radius or icon name; checked in light and dark across the themes.
- Nothing blocks input while it animates, and nothing animates `width`, `height`, `top` or `left` except a height collapse.
- Then run `web-design-guidelines` on the result, per `CLAUDE.md`.
Install it elsewhere with curl -o .claude/skills/interaction-design/SKILL.md --create-dirs https://uxlab.designcoder.net/api/skills/interaction-design/source. Record what you changed in this page's notes.