---
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`.
