Icons and motion
Read when: adding an icon, an animation or a transition, or considering a new icon set or animation library. Owns: the icon package and how icons are imported, sized, coloured and named; when to animate, with what, and the guards every animation needs.
TL;DR
- One icon package:
@lucide/vue, named imports with theIconsuffix (PencilIcon). - Inside a shadcn component, never size the icon; outside, size with
size-*. - Colour comes from the parent's
text-*; decorative icons getaria-hidden="true". - Working screens need little motion: overlays from the registry already animate.
- Every animation is off under
prefers-reduced-motion: reduceand in print. - No animation library without a need CSS and Vue's
<Transition>cannot meet.
Icons
<script setup lang="ts">
import { MoonIcon, SunIcon } from '@lucide/vue';
</script>
<template>
<Button variant="ghost" size="icon" :aria-label="t('common.toggle_theme')">
<SunIcon v-if="isDark" aria-hidden="true" />
<MoonIcon v-else aria-hidden="true" />
</Button>
</template>
@lucide/vueis what shadcn-vue's CLI resolvesiconLibrary: "lucide"to, notlucide-vue-next(checked 2026-11-03).- The
Iconsuffix (LinkIcon,TableIcon) keeps an icon from clashing with a component of the same name (Link,Table) in one file. - Always a named import.
import * as iconspulls the whole set into the bundle. - One icon package per app. Two sets in one interface look like a bug. When Lucide lacks a
glyph, draw one component on Lucide's 24px grid with a 2px round stroke, or add
unplugin-iconswith one Iconify set; never paste one-off SVG paths into pages. - Inside
Button,Badgeand friends, don't size the icon. They size it per variant. Outside them,size-4besidetext-sm,size-3.5besidetext-xs. aria-hidden="true"on every decorative icon. The accessible name goes on the control.- Colour comes from the parent's
text-*. Never setcolororstrokeon the icon. - One concept, one icon across the app. Which icon goes with which action is in actions-and-feedback.md.
- Directional icons flip in RTL (rtl-and-bidi.md).
- Emoji are content, never chrome.
☀️renders as a platform colour glyph that ignorescurrentColor, so a themed icon button cannot match its own text.
Motion
| Need | Use |
|---|---|
| an overlay opening (dialog, menu, popover, sheet) | the registry's own animation; add nothing |
| an element appearing on load | one small CSS keyframe utility (opacity plus translateY) |
| one element swapping for another | Vue's <Transition> |
| a list gaining, losing or reordering items | <TransitionGroup> with a stable key |
- Instant for what people open many times a day. A menu or a picker opened from the keyboard dozens of times an hour gains nothing from a fade; the wait is the only thing it adds.
- Fade with
animation-duration-*, notduration-*, on a registry overlay:duration-*also setstransition-duration, and with no property list every property transitions. - Direction-agnostic transforms (opacity,
translateY, scale). A hand-writtentranslateXorleftmoves the wrong way in RTL unless it has anrtl:twin. - Don't add motion to tables or forms for its own sake.
Two guards are not optional:
@media (prefers-reduced-motion: reduce), print {
.rise {
animation: none;
}
}
An animation that survives into print or a PDF export is a bug.
Adding an animation library
Don't, without a concrete need the table above cannot meet. Bundle size matters most to people on mobile data. An evaluation (recorded 2026-09; re-check before relying on it):
| Package | Verdict |
|---|---|
@formkit/auto-animate |
fine for list add, remove and reorder, nothing else; about 3 kB gzipped, no dependencies, handles reduced motion itself |
motion-v |
the upgrade if spring physics or layout animation is ever needed; large |
@vueuse/motion |
avoid: depends on unmaintained popmotion / framesync |
gsap |
avoid here: not MIT-licensed, large |