Particles

A burst of particles around an element, for likes, stars and other celebrations.

"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
  Particles,
  ParticlesEffect,
  type ParticlesEffectProps,
} from "@animate-ui/react/primitives/effects/particles"
import { FavouriteIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "cn"

import { Button } from "@/components/ui/button"

type ParticlesDemoProps = Pick<
  ParticlesEffectProps,
  | "side"
  | "sideOffset"
  | "align"
  | "alignOffset"
  | "count"
  | "radius"
  | "spread"
  | "duration"
  | "holdDelay"
  | "delay"
>

const EASE_OUT = [0.23, 1, 0.32, 1] as const

function Label({ children }: { children: string }) {
  const reduce = useReducedMotion()
  const ref = React.useRef<HTMLSpanElement>(null)
  const [width, setWidth] = React.useState<number | "auto">("auto")

  React.useLayoutEffect(() => {
    const el = ref.current
    if (!el) return
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry!.borderBoxSize[0]!.inlineSize)
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [])

  return (
    <motion.span
      aria-hidden="true"
      className="relative inline-flex"
      initial={false}
      animate={{ width }}
      transition={reduce ? { duration: 0 } : { duration: 0.3, ease: EASE_OUT }}
    >
      <span ref={ref} className="inline-flex">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={children}
            initial={
              reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }
            }
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={
              reduce
                ? { opacity: 0 }
                : { opacity: 0, y: -8, filter: "blur(2px)" }
            }
            transition={{ duration: 0.2, ease: EASE_OUT }}
          >
            {children}
          </motion.span>
        </AnimatePresence>
      </span>
    </motion.span>
  )
}

export default function ParticlesDemo(props: ParticlesDemoProps) {
  const [liked, setLiked] = React.useState(false)
  const [burst, setBurst] = React.useState(0)
  const reduce = useReducedMotion()

  return (
    <Particles animate={liked}>
      <Button
        variant="outline"
        aria-pressed={liked}
        aria-label={liked ? "Liked" : "Like"}
        onClick={() => {
          setLiked((value) => !value)
          setBurst((value) => value + 1)
        }}
      >
        <motion.span
          className="inline-flex"
          initial={false}
          animate={{
            scale: reduce ? 1 : liked ? [1, 1.3, 1] : [1, 0.85, 1],
          }}
          transition={{
            duration: 0.4,
            times: [0, 0.4, 1],
            ease: ["easeOut", [0.34, 1.56, 0.64, 1]],
          }}
        >
          <HugeiconsIcon
            icon={FavouriteIcon}
            strokeWidth={2}
            className={cn(
              "fill-transparent transition-[fill,color] duration-200 ease-out",
              liked && "fill-red-500 text-red-500"
            )}
          />
        </motion.span>
        <Label>{liked ? "Liked" : "Like"}</Label>
      </Button>
      <ParticlesEffect
        key={burst}
        {...props}
        className="size-1 rounded-full bg-red-500"
      />
    </Particles>
  )
}

Installation

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Usage

import { Particles, ParticlesEffect } from '@animate-ui/react/primitives/effects/particles';

export function LikeButton({ liked }: { liked: boolean }) {
  return (
    <Particles animate={liked}>
      <button>Like</button>
      <ParticlesEffect className="size-1 rounded-full bg-red-500" />
    </Particles>
  );
}

The burst plays when animate becomes true. Remount Particles (with a key) to replay it.

Animation

  • count particles shoot out from a point on one side of the element, spread over spread degrees, then shrink and fade out (800ms, ease-out). Each starts holdDelay seconds after the previous one.
  • Particles are absolutely positioned: the layout never moves.
  • With inView, the burst waits for the element to be on screen.
  • Under reduced motion, the burst is skipped: it's decorative.

API Reference

Particles

PropTypeDefault
asChild?
booleanfalse
animate?
booleantrue
children
React.ReactNode-
inView?
booleanfalse
inViewOnce?
booleantrue
inViewMargin?
string'0px'
...props?
HTMLMotionProps<'div'>-

ParticlesEffect

Each particle is rendered with these props: style it with className.

PropTypeDefault
side?
'top' | 'bottom' | 'left' | 'right''top'
sideOffset?
number0
align?
'start' | 'center' | 'end''center'
alignOffset?
number0
count?
number6
radius?
number30
spread?
number360
duration?
number0.8
holdDelay?
number0.05
delay?
number0
transition?
Transition-
...props?
HTMLMotionProps<'div'>-

Credits

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page