Matthew W Buckley

Inline Components Demo

March 18, 2024
react

I saw this really nice component in a recent (at time of writing) blog post by Josh W Comeau where he had a little twinkles around some text. Take a look here: https://www.joshwcomeau.com/blog/whimsical-animations/

So, I decided to make my own - but worse! What I am imagining is a nice group of SVGs that are created and have some animation on hover.

Also, I am going with a heavily edited "code along" style for this. Lets see how it goes.

Lets Begin

The first step is creating a dynamic icon component that can render any icon from the Lucide library using just its name.

Here's our base DynamicIcon component that handles this:

import React from 'react';
import * as Icons from 'lucide-react';
import { LucideIcon } from 'lucide-react';

interface DynamicIconProps {
    name: keyof typeof Icons;
    color?: string;
    size?: number;
    className?: string;
}

export const DynamicIcon: React.FC<DynamicIconProps> = ({ name, color = 'black', size = 16, className }) => {
    const Icon = Icons[name] as LucideIcon;

    if (!Icon) {
        console.warn(`Icon "${name}" not found`);
        return null;
    }

    return <Icon color={color} size={size} className={className} />;
};

So now we have a icon that can be anything from Lucide in a colour and size that we want.

Next, I'm building a component that spawns these icons with animations. The icons will:

  1. Start from random positions within the container
  2. Move outward based on their position relative to the center
  3. Rotate during the animation
  4. Fade in while scaling up
  5. Use react-spring for smooth animations

And here I ran into the only problem in this project. The <animate.span> does not play well with typescript. This may be because I am using React 19 and React 18 removed children from the default props. I don't want to spend too much time on this so Claude had a work around for me to use; using the animate as a function - animate('span'). Now, why the generic type returned from animate works, where animate.span or animate.div does not? No idea.

At least now we can implement our IconSpawner. I'll put up the full code in case someone wants it. Its not the best, and it doesn't really need to be for my needs. I'll go over some of the more interesting things in a bit.

interface IconPosition {
    x: number;
    y: number;
    icon: keyof typeof Icons;
    dx: number;
    dy: number;
    startRotation: number;
    endRotation: number;
    color: string;
    iconSize: number;
}

interface IconSpawnerProps {
    children: React.ReactNode;
    numIcons?: number;
    color?: string;
    icon?: keyof typeof Icons;
    iconSize?: number;
}

export const IconSpawner: React.FC<IconSpawnerProps> = ({ 
    children, 
    numIcons = 8,
    color = 'oklch(13.98% 0 0 / 0.5)',
    icon = 'Sparkle' as keyof typeof Icons,
    iconSize = '20'
}) => {
    const containerRef = useRef<HTMLSpanElement>(null);
    const [icons, setIcons] = useState<IconPosition[]>([]);
    const [isHovered, setIsHovered] = useState(false);

	const getRandomIcon = (): keyof typeof Icons => {
        const iconNames = Object.keys(Icons) as (keyof typeof Icons)[];
        return iconNames[Math.floor(Math.random() * iconNames.length)];
    };

    const generateIcons = () => {
        if (!containerRef.current) return;

        const { width, height } = containerRef.current.getBoundingClientRect();

        const centerX = width / 2;
        const centerY = height / 2;
        const maxDistance = Math.min(width, height) * 0.8;
        const newIcons: IconPosition[] = [];

        for (let i = 0; i < numIcons; i++) {
            const distance = Math.random() * maxDistance;
            const x = centerX + (Math.random() * width - width/2);
            const y = centerY + (Math.random() * height - height/2);
            const dx = (x - centerX)/centerX * distance;
            const dy = (-y)/centerY * distance;

            const startRotation = Math.random() * 360;
            const endRotation = startRotation + (Math.random() * 720 - 360);

            newIcons.push({
                x,
                y,
                dx,
                dy,
                startRotation,
                endRotation,
                color: color,
                icon: icon || getRandomIcon(),
                size: iconSize
            });
        }

        setIcons(newIcons);
    };

    return (
        <span
            ref={containerRef}
            className="relative inline-block p-2"
            onMouseEnter={() => {
                setIsHovered(true);
                generateIcons();
            }}
            onMouseLeave={() => setIsHovered(false)}
        >
            {children}
            <>
            {isHovered && icons.map((icon, index) => (
                <AnimatedIcon 
                    key={index}
                    icon={icon}
                    delay={index * 0}
                />
            ))}
            </>
        </span>
    );
};

The animation logic uses react-spring to create smooth transitions. Each icon moves outward based on its position relative to the center, while also rotating:

const AnimatedIcon: React.FC<AnimatedIconProps> = ({ icon, delay }) => {
    const styles = useSpring({
        from: { 
            opacity: 0,
            transform: `translate(${icon.x}px, ${icon.y}px) scale(0.5) rotate(${icon.startRotation}deg)`,
        },
        to: { 
            opacity: 1,
            transform: `translate(${icon.x + icon.dx}px, ${icon.y + icon.dy}px) scale(1) rotate(${icon.endRotation}deg)`,
        },
        config: { ...config.gentle, tension: 100 },
        delay,
    });

    return (
        <AnimatedSpan
            className="absolute left-0 top-0 pointer-events-none inline-flex"
            style={styles}
        >
            <DynamicIcon name={icon.icon} size={icon.size} color={icon.color} />
        </AnimatedSpan>
    );
};

So, the thing that I find interesting is the calculation of initial and final position. I made x and y be a random position within the bounding rect in a pointlessly complex way. Since centerX == width/2 I could have easily just used const x = Math.random() * width. So why did I do it like this? I just like writing it like that, it allows the quick addition of a scaling factor while maintaining symmetry. Will I ever add one? Should I have followed YAGNI? Of course.

// overly complex random position calculation 
const x = centerX + (Math.random() * width - width/2);
const y = centerY + (Math.random() * height - height/2);


const dx = (x - centerX)/centerX * distance;
const dy = (-y)/centerY * distance;

The calculation of the offset is also quite interesting. Lets look at dx first.

We get the distance from the center and divide by centerX, essentially normalisation to the width sp moves the same as the short axis. We then multiply a scaling factor to get a semi-consistent magnitude. Result is the icon moves away from the center more the further from the center it is.

And initially dy was the same, but it felt a little sad. I thought moving the icons upwards would be more optimistic. So instead we can simply use -y as we want it to move upwards.

I have not used react-spring much, and apart from the annoying type issue it seems to be pretty easy to use and gives nice smooth animations. I settled on gentle and a low tension in the end. Originally they were a bit more wobbly.

config: { ...config.gentle, tension: 100 }

Conclusions and show-off

Wow, that code along only had like two steps. I think I will want to change how I go about writing these if I want to do this properly. Apart from that, I think it went pretty well. Almost exactly what I pictured (I wanted an arc, but I have no idea how to write the math). Its a shame I didn't pick an icon library with a fill colour. I may have to rethink making Lucide my standard for this blog. Also, it would have been nice to make the distance a variable, but its already midnight.

So lets see it with my blogs primary colour. And some different shapes.

Thanks for reading