UIPackage

Icon Transition

React feedback
Edit on GitHub

Animated icon swap for click acknowledgements — copy → check, bookmark → bookmark-filled, follow → follower. Pass two icon components and an async `action`; clicking runs the action then springs the active icon in. Auto-reverts after `resetAfter` ms (default 1500), or pass `0` to keep the active icon and call the exposed `reset()` method to flip it back. Renders as a `<button>` by default; use `as="span"` plus the `active` prop when you want an externally controlled, non-interactive icon swap inside another control. Honors `prefers-reduced-motion`.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/icon-transition.json

Or with the named registry: npx shadcn@latest add @uipkge-react/icon-transition

Examples

Props

Name Type / Values Default Required
defaultIcon IconComponent required
activeIcon IconComponent required
iconClass

Tailwind size/color applied to the icons.

string optional
action

Async work executed on click before the icon flips. Return `false` to skip the flip.

() => boolean | void | Promise<boolean | void> optional
resetAfter

ms before reverting to defaultIcon. Set to `0` (or null) to stay active.

number | null optional
duration

Pop animation duration in ms (also scales the leave fade).

number optional
label

aria-label shown in default state.

string optional
activeLabel

aria-label shown after activation; falls back to `label`.

string optional
activeClass

Tailwind class applied while active (e.g. "text-success").

string optional
as

Render element. `button` adds click handler + focus styling; `span` is purely visual.

'button' | 'span' optional
active

External control. When provided, this prop wins over internal state.

boolean optional
onActivate () => void optional
onReset () => void optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

IconTransitionHandle
interface IconTransitionHandle {
  trigger: () => Promise<void>
  reset: () => void
}

Files (2)

  • components/ui/icon-transition/icon-transition.tsx 6.8 kB
    'use client'
    
    import * as React from 'react'
    
    // Self-contained icon-swap control. Click runs an optional async `action`,
    // then flips from `defaultIcon` to `activeIcon` with a spring pop. By default
    // it auto-reverts after 1500ms; pass `resetAfter={0}` to keep the active
    // icon, then call `ref.current.reset()` manually.
    //
    // Two operating modes:
    //   1. Self-managed (most common): pass `defaultIcon`, `activeIcon`, and
    //      either `action` or just listen to `onActivate`. The component runs
    //      the action, manages its own `active` state, and auto-resets.
    //   2. Externally controlled: pass `active={boolean}` and the component
    //      will mirror that prop, ignoring its internal flag.
    //
    // Renders as a <button> by default. Pass `as="span"` for a non-interactive
    // icon (e.g. inside a parent button) — in that mode the parent is responsible
    // for triggering `trigger()` via the exposed ref.
    
    type IconComponent = React.ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>
    
    export interface IconTransitionProps {
      defaultIcon: IconComponent
      activeIcon: IconComponent
      /** Tailwind size/color applied to the icons. */
      iconClass?: string
      /** Async work executed on click before the icon flips. Return `false` to skip the flip. */
      action?: () => boolean | void | Promise<boolean | void>
      /** ms before reverting to defaultIcon. Set to `0` (or null) to stay active. */
      resetAfter?: number | null
      /** Pop animation duration in ms (also scales the leave fade). */
      duration?: number
      /** aria-label shown in default state. */
      label?: string
      /** aria-label shown after activation; falls back to `label`. */
      activeLabel?: string
      /** Tailwind class applied while active (e.g. "text-success"). */
      activeClass?: string
      /** Render element. `button` adds click handler + focus styling; `span` is purely visual. */
      as?: 'button' | 'span'
      /** External control. When provided, this prop wins over internal state. */
      active?: boolean
      onActivate?: () => void
      onReset?: () => void
    }
    
    export interface IconTransitionHandle {
      trigger: () => Promise<void>
      reset: () => void
    }
    
    // Scoped styles injected once. Mirrors the Vue SFC's scoped <style> block 1:1
    // (pop on enter, fade on leave, reduced-motion guard). Scoped to the
    // `.icon-transition` ancestor so the keyframes don't leak.
    const STYLE_ID = 'uipkge-icon-transition-styles'
    const STYLES = `
    .icon-transition .icon-transition-slot {
      grid-area: 1 / 1;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }
    .icon-transition .icon-transition-enter-active,
    .icon-transition .icon-transition-leave-active {
      transform-origin: center;
    }
    .icon-transition .icon-transition-enter-active {
      animation: icon-transition-pop var(--it-duration, 240ms) cubic-bezier(0.34, 1.56, 0.64, 1) both;
    }
    .icon-transition .icon-transition-leave-active {
      animation: icon-transition-fade calc(var(--it-duration, 240ms) * 0.66) ease-in both;
    }
    @keyframes icon-transition-pop {
      0% {
        opacity: 0;
        transform: scale(0.5) rotate(-12deg);
      }
      60% {
        opacity: 1;
        transform: scale(1.18) rotate(2deg);
      }
      100% {
        opacity: 1;
        transform: scale(1) rotate(0deg);
      }
    }
    @keyframes icon-transition-fade {
      to {
        opacity: 0;
        transform: scale(0.85);
      }
    }
    @media (prefers-reduced-motion: reduce) {
      .icon-transition .icon-transition-enter-active,
      .icon-transition .icon-transition-leave-active {
        animation-duration: 0ms !important;
      }
    }
    `
    
    function useInjectedStyles() {
      React.useEffect(() => {
        if (typeof document === 'undefined') return
        if (document.getElementById(STYLE_ID)) return
        const el = document.createElement('style')
        el.id = STYLE_ID
        el.textContent = STYLES
        document.head.appendChild(el)
      }, [])
    }
    
    const IconTransition = React.forwardRef<IconTransitionHandle, IconTransitionProps>(function IconTransition(
      {
        defaultIcon: DefaultIcon,
        activeIcon: ActiveIcon,
        iconClass = 'size-4',
        action,
        resetAfter = 1500,
        duration = 240,
        label,
        activeLabel,
        activeClass = 'text-success',
        as = 'button',
        active,
        onActivate,
        onReset,
      },
      ref,
    ) {
      useInjectedStyles()
    
      const [internalActive, setInternalActive] = React.useState(false)
      const isActive = active !== undefined ? active : internalActive
    
      // Track the previous active state so we can render an outgoing (leaving)
      // icon alongside the incoming (entering) one for the cross-fade — Vue's
      // <Transition> does this for free; React needs us to hold the prior key.
      const [prevActive, setPrevActive] = React.useState<boolean | null>(null)
      const prevIsActiveRef = React.useRef(isActive)
      React.useEffect(() => {
        if (prevIsActiveRef.current !== isActive) {
          setPrevActive(prevIsActiveRef.current)
          prevIsActiveRef.current = isActive
        }
      }, [isActive])
    
      const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
    
      const clearTimer = React.useCallback(() => {
        if (timerRef.current) {
          clearTimeout(timerRef.current)
          timerRef.current = null
        }
      }, [])
    
      const reset = React.useCallback(() => {
        setInternalActive(false)
        onReset?.()
        clearTimer()
      }, [onReset, clearTimer])
    
      const trigger = React.useCallback(async () => {
        if (action) {
          const result = await action()
          if (result === false) return
        }
        setInternalActive(true)
        onActivate?.()
        clearTimer()
        if (resetAfter && resetAfter > 0) {
          timerRef.current = setTimeout(reset, resetAfter)
        }
      }, [action, onActivate, clearTimer, resetAfter, reset])
    
      React.useImperativeHandle(ref, () => ({ trigger, reset }), [trigger, reset])
    
      React.useEffect(() => clearTimer, [clearTimer])
    
      const Comp = as
    
      const renderSlot = (slotActive: boolean, phase: 'enter' | 'leave', key: string) => {
        const SlotIcon = slotActive ? ActiveIcon : DefaultIcon
        const phaseClass = phase === 'enter' ? 'icon-transition-enter-active' : 'icon-transition-leave-active'
        return (
          <span key={key} className={`icon-transition-slot ${phaseClass}`}>
            <SlotIcon className={iconClass} aria-hidden="true" />
          </span>
        )
      }
    
      return (
        <Comp
          {...(as === 'button' ? { type: 'button' as const } : {})}
          aria-label={isActive ? (activeLabel ?? label) : label}
          aria-live={isActive ? 'polite' : undefined}
          className={[
            'icon-transition focus-visible:ring-ring inline-grid place-items-center transition-colors focus-visible:ring-2 focus-visible:outline-none',
            isActive ? activeClass : '',
          ].join(' ')}
          style={{ ['--it-duration' as string]: `${duration}ms` }}
          onClick={as === 'button' ? () => void trigger() : undefined}
        >
          {prevActive !== null && prevActive !== isActive && renderSlot(prevActive, 'leave', `leave-${prevActive}`)}
          {renderSlot(isActive, 'enter', `enter-${isActive}`)}
        </Comp>
      )
    })
    
    export { IconTransition }
  • components/ui/icon-transition/index.ts 0.1 kB
    export { IconTransition, type IconTransitionProps, type IconTransitionHandle } from './icon-transition'

Raw manifest: https://react.uipkge.dev/r/react/icon-transition.json