Why Sparkles?

HTML gives us <strong> for urgent warnings and <em> for verbal stress, but both skew serious or tense. For marking something positively — new, shiny, or especially liked — there isn't a built-in semantic tool. A sparkle effect built as a reusable React component fills that gap, and it doesn't have to be limited to text. You can dot sparkles over images, buttons, or any other element.

The goal: a component that renders its children inside a positioned wrapper and periodically emits small SVG sparkles at random positions. Each sparkle twinkles once via CSS animation, then gets cleaned up so the DOM doesn't fill with dead nodes.

The Sparkle Asset

The sparkle itself is an SVG, not a raster image. Using inline SVG means the fill color can be changed dynamically from JavaScript. You can source an SVG from a library like the Noun Project, download a pre-made file, or hand-draw one in a tool like Figma and export it.

A typical exported SVG looks like:

<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
  <path d="M80 0C80 0 84.2846 41.2925 101.496 58.504C118.707 75.7154 160 80 160 80C160 80 118.707 84.2846 101.496 101.496C84.2846 118.707 80 160 80 160C80 160 75.7154 118.707 58.504 101.496C41.2925 84.2846 0 80 0 80C0 80 41.2925 75.7154 58.504 58.504C75.7154 41.2925 80 0 80 0Z" fill="#FFC700"></path>
</svg>

SVG code is nearly valid JSX already. A converter like svg2jsx fixes the few syntactic differences.

Data Model and Rendering

Each sparkle needs a unique ID plus random values for its size and its horizontal/vertical position within the parent. Layout positions are stored as percentages (e.g. 50%) because the component doesn't know the container's real pixel dimensions.

That raw data is fed into a small component that owns the markup. The SVG's previously hard-coded attributes — dimensions, color, position — all become props. The resulting component is wrapped in a styled-element layer that handles baseline positioning.

The public API takes an array (or children) and wraps them in an element with a higher stacking context. Sparkles get their own layer with a lower z-index, so they slip in behind the content:

  • Wrapper: the outer positioned container.
  • SparkleInstance: rendered at a random percentage position, with its own color/size.
  • ChildWrapper: the user's content, given a z-index of 1.

Twinkling with Keyframes

The effect is a combination of two transform-based changes — rotation and scale. If both live on the same keyframe rule they'll run in lockstep, creating a mechanical two-step motion rather than a shimmer. Splitting them across two different elements is the fix: a nested element owns a linear rotation animation, while the outer wrapper runs a symmetric scale animation. This decoupling also allows separate easing curves, producing an organic, sparkling feel instead of a jerky pause at the 50% midpoint.

function SparkleInstance({ color, size, style }) {
  return (
    <Wrapper>
      <Svg>
        {/* Same stuff here */}
      </Svg>
    </Wrapper>
  );
}

const growAndShrink = keyframes`
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1);
  }
  100% {
    transform: scale(0);
  }
`;

const spin = keyframes`
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(180deg);
  }
`;

const Wrapper = styled.div`
  position: absolute;
  pointer-events: none;
  animation: ${growAndShrink} 600ms ease-in-out forwards;
`

const Svg = styled.svg`
  animation: ${spin} 600ms linear forwards;
`;

Random Generation and Cleanup

A plain setInterval firing every 500ms produces a robotic, staccato rhythm. A better approach is a custom hook that accepts a min and max delay and picks randomly inside that range on every iteration. This smooths out the cadence while keeping the average rate the same.

Inside that interval loop, two tasks happen: append a new sparkle instance to state, and sweep away all existing sparkles whose animations have completed.

function Sparkles({ children }) {
  const [sparkles, setSparkles] = React.useState([]);

  useRandomInterval(() => {
    const now = Date.now();

    // Create a new sparkle
    const sparkle = generateSparkle();

    // Clean up any "expired" sparkles
    const nextSparkles = sparkles.filter(sparkle => {
      const delta = now - sparkle.createdAt;
      return delta < 1000;
    });

    // Include our new sparkle
    nextSparkles.push(sparkle);

    // Make it so!
    setSparkles(nextSparkles);
  }, 50, 500);

  return (
    <Wrapper>
      {children}
    </Wrapper>
  )
}

const Wrapper = styled.span`
  position: relative;
  display: inline-block;
`;

Reduced Motion Support

Whimsy shouldn't override user preferences. The prefers-reduced-motion media query is the signal, and the hook usePrefersReducedMotion surfaces that value in React code.

Two things change when it returns true:

  1. The random interval loop is turned off entirely by passing null as the min/max delay.
  2. A small, static set of sparkles (three or four) is shown without animation.

The static sparkles become the initial state of the sparkles collection. A final CSS safeguard locks them down:

const Wrapper = styled.div`
  position: absolute;
  pointer-events: none;

  @media (prefers-reduced-motion: no-preference) {
    animation: ${growAndShrink} 600ms ease-in-out forwards;
  }
`;

const Svg = styled.svg`
  @media (prefers-reduced-motion: no-preference) {
    animation: ${spin} 600ms linear forwards;
  }
`;

Assembly

The final version depends on a handful of reusable utilities: a random number function, a range helper, plus the usePrefersReducedMotion and useRandomInterval hooks.

Beyond the Baseline

This is a minimal skeleton rather than a finished product. On an actual blog or app you may want to:

  • Control whether sparkles render in front or behind the content.
  • Harness the IntersectionObserver API so sparkles only run when the element is on screen.
  • Allow a click to dismiss the effect.
  • Tune the random placement to favor more deliberate compositions.

The value of building it from scratch instead of shipping an opaque NPM package is that every part — the sprite, the animation timing, the placement logic — is available for customization.