Skip to content

Icon Swap ​

Button that morphs between icon states — copy to check, play to pause — with an optional particle burst.

$pnpm dlx shadcn-vue add …/icon-swap.json

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/icon-swap.json
Manual installation

Install the dependencies:

bash
npm install motion-v class-variance-authority

Then copy these files into components/ui/icon-swap/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts" generic="V">
import type { HTMLAttributes } from 'vue'
import type { IconSwapState, IconSwapVariants } from '.'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, ref } from 'vue'
import { cn } from '@/lib/utils'
import { iconSwapVariants } from '.'

const props = withDefaults(defineProps<{
  /** The states to cycle through, in order. Clicking moves to the next one. */
  states: IconSwapState<V>[]
  /** How the icons swap. */
  effect?: 'rotate' | 'blur' | 'scale'
  variant?: IconSwapVariants['variant']
  size?: IconSwapVariants['size']
  /** Render the state label as visible text next to the icon. */
  showLabel?: boolean
  /** Return to the first state after this many ms (e.g. copy → check → copy). */
  resetAfter?: number
  disabled?: boolean
  class?: HTMLAttributes['class']
}>(), {
  effect: 'blur',
  showLabel: false,
  disabled: false,
})

const model = defineModel<V>()
const emit = defineEmits<{ change: [value: V] }>()

const reduced = useReducedMotion()

const index = computed(() => Math.max(0, props.states.findIndex(s => Object.is(s.value, model.value))))
const current = computed(() => props.states[index.value])

let resetTimer: ReturnType<typeof setTimeout> | undefined
let burstTimer: ReturnType<typeof setTimeout> | undefined
const burstId = ref(0)
const bursting = ref(false)

function go(next: number) {
  const state = props.states[next]
  if (!state)
    return
  model.value = state.value
  emit('change', state.value)
  if (state.burst && !reduced.value) {
    burstId.value++
    bursting.value = true
    clearTimeout(burstTimer)
    burstTimer = setTimeout(() => (bursting.value = false), 800)
  }
}

function onClick() {
  // A bound v-model only updates after the parent re-renders, so work from the computed next index.
  const next = (index.value + 1) % props.states.length
  go(next)
  clearTimeout(resetTimer)
  if (props.resetAfter && next !== 0)
    resetTimer = setTimeout(() => go(0), props.resetAfter)
}

onBeforeUnmount(() => {
  clearTimeout(resetTimer)
  clearTimeout(burstTimer)
})

const effects = {
  rotate: { initial: { opacity: 0, rotate: -120, scale: 0.4 }, exit: { opacity: 0, rotate: 120, scale: 0.4 } },
  blur: { initial: { opacity: 0, scale: 0.5, filter: 'blur(4px)' }, exit: { opacity: 0, scale: 0.5, filter: 'blur(4px)' } },
  scale: { initial: { opacity: 0, scale: 0 }, exit: { opacity: 0, scale: 0 } },
}
const swap = computed(() => (reduced.value
  ? { initial: { opacity: 0 }, exit: { opacity: 0 } }
  : effects[props.effect]))
const spring = computed(() => ({
  type: 'spring' as const,
  stiffness: props.effect === 'scale' ? 520 : 420,
  damping: props.effect === 'scale' ? 17 : 28,
  // springs overshoot — keep blur on a tween so it never goes negative
  filter: { duration: 0.2 },
}))

// Eight particles flung outward, alternating near and far.
const particles = Array.from({ length: 8 }, (_, i) => {
  const angle = (i / 8) * Math.PI * 2 - Math.PI / 2
  const r = i % 2 ? 17 : 25
  return { x: Math.cos(angle) * r, y: Math.sin(angle) * r, size: i % 2 ? 'size-1' : 'size-1.5' }
})
</script>

<template>
  <motion.button
    type="button"
    :layout="props.showLabel"
    :disabled="props.disabled"
    :aria-label="props.showLabel ? undefined : current?.label"
    :while-press="props.disabled ? undefined : { scale: 0.9 }"
    :transition="{ type: 'spring', stiffness: 500, damping: 30 }"
    :class="cn(iconSwapVariants({ variant: props.variant, size: props.size }), current?.class, props.class)"
    @click="onClick"
  >
    <span class="relative inline-grid place-items-center" aria-hidden="true">
      <AnimatePresence mode="popLayout" :initial="false">
        <motion.span
          :key="index"
          class="inline-grid place-items-center"
          :initial="swap.initial"
          :animate="{ opacity: 1, rotate: 0, scale: 1, filter: 'blur(0px)' }"
          :exit="swap.exit"
          :transition="spring"
        >
          <slot name="icon" :state="current" :index="index">
            <component :is="current.icon" v-if="current?.icon" />
          </slot>
        </motion.span>
      </AnimatePresence>

      <!-- Burst: a ring and a ring of particles, re-keyed so every switch replays it -->
      <span v-if="bursting" class="pointer-events-none absolute inset-0 grid place-items-center [&>*]:col-start-1 [&>*]:row-start-1">
        <motion.span
          :key="`ring-${burstId}`"
          class="size-6 rounded-full border-2 border-current"
          :initial="{ scale: 0.3, opacity: 0.7 }"
          :animate="{ scale: 1.7, opacity: 0 }"
          :transition="{ duration: 0.5, ease: 'easeOut' }"
        />
        <motion.span
          v-for="(p, i) in particles"
          :key="`p-${burstId}-${i}`"
          :class="cn('rounded-full bg-current', p.size)"
          :initial="{ x: 0, y: 0, scale: 1, opacity: 1 }"
          :animate="{ x: p.x, y: p.y, scale: [1, 1, 0], opacity: [1, 1, 0] }"
          :transition="{ duration: 0.6, ease: [0.2, 0.8, 0.3, 1], delay: 0.04, scale: { duration: 0.7, times: [0, 0.55, 1], delay: 0.04 }, opacity: { duration: 0.7, times: [0, 0.55, 1], delay: 0.04 } }"
        />
      </span>
    </span>

    <span v-if="props.showLabel" class="relative inline-grid overflow-hidden">
      <AnimatePresence mode="popLayout" :initial="false">
        <motion.span
          :key="index"
          class="inline-block"
          :initial="reduced ? { opacity: 0 } : { opacity: 0, y: 10, filter: 'blur(3px)' }"
          :animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
          :exit="reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: 'blur(3px)' }"
          :transition="{ type: 'spring', stiffness: 420, damping: 32, filter: { duration: 0.2 } }"
        >
          {{ current?.label }}
        </motion.span>
      </AnimatePresence>
    </span>
  </motion.button>
</template>
ts
import type { VariantProps } from 'class-variance-authority'
import type { Component, HTMLAttributes } from 'vue'
import { cva } from 'class-variance-authority'

export { default as IconSwap } from './IconSwap.vue'

export interface IconSwapState<V = unknown> {
  /** Model value while this state is shown. */
  value: V
  /** Accessible name of the button in this state (also the visible text with `showLabel`). */
  label: string
  /** Icon component, e.g. from lucide-vue-next. Or use the `#icon` slot. */
  icon?: Component
  /** Extra classes on the button while this state is active (colour, fill…). */
  class?: HTMLAttributes['class']
  /** Play a particle burst when the button switches into this state. */
  burst?: boolean
}

export const iconSwapVariants = cva(
  'relative inline-flex select-none items-center justify-center gap-2 rounded-lg text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        ghost: 'text-foreground hover:bg-accent',
        outline: 'border bg-background text-foreground shadow-xs hover:bg-accent',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        default: 'bg-primary text-primary-foreground shadow-sm',
      },
      size: {
        sm: 'h-8 min-w-8 px-2 text-xs [&_svg]:size-3.5',
        md: 'h-9 min-w-9 px-2.5 [&_svg]:size-4',
        lg: 'h-11 min-w-11 px-3 text-base [&_svg]:size-5',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
    },
  },
)

export type IconSwapVariants = VariantProps<typeof iconSwapVariants>

Usage ​

ts
import { IconSwap } from '@/components/ui/icon-swap'
import { Heart } from 'lucide-vue-next'
vue
<script setup lang="ts">
const liked = ref(false)
const states = [
  { value: false, label: 'Like', icon: Heart },
  { value: true, label: 'Unlike', icon: Heart, class: 'text-rose-500 [&_svg]:fill-current', burst: true },
]
</script>

<template>
  <IconSwap v-model="liked" :states="states" effect="scale" variant="ghost" />
</template>

Each click moves to the next state (wrapping around), so two states make a toggle and more make a cycle. label is the button's accessible name in that state — describe what the button shows or does, e.g. Copy → Copied. For copy buttons, reset-after returns to the first state automatically.

API ​

PropTypeDefaultDescription
statesIconSwapState[]—States to cycle through: { value, label, icon?, class?, burst? }.
v-modelVfirst stateValue of the current state.
effect'rotate' | 'blur' | 'scale''blur'How icons swap.
variant'ghost' | 'outline' | 'secondary' | 'default''outline'Visual style.
size'sm' | 'md' | 'lg''md'Button size.
showLabelbooleanfalseShow the label as text next to the icon.
resetAfternumber—Return to the first state after this many ms.
disabledbooleanfalseDisables the button.
EventPayloadDescription
changevalue: VFired when the state changes (click or reset).
SlotPropsDescription
icon{ state, index }Custom icon per state instead of state.icon.

Designed by Kuldeep Pisda · Released under the MIT License