The motivation
We know that using the same combinations of ready-made elements without deeply customizing them, will most likely lead to a déjà-vu sentiment in the user of our web products.
Unfortunately, in the case of SaaS products, where differentiation and first impressions are critical, this sameness dilutes brand identity and makes it harder to stand out in a competitive market. Hero sections, in particular, tend to suffer from a formulaic structure: a headline, subtext, call-to-action buttons, and an accompanying image or illustration. When combined with the popularity of Tailwind and shadcn/ui, many developers produce functionally correct but interchangeable layouts.
This is exacerbated by time constraints, as focusing on functionality takes precedence over creative exploration.
Let’s say we are creating an Horoscope app, AI-powered obviously, as we are almost in 2025 now.
A question then arises. We have seen components over React world that look like a starry night, but if we just copy them, how lame would it be?
Wouldn’t be cool if we could write lines with low opacity between a few of those “stars” to create the feeling of a zodiac constellation?
That would suffice to prevent a déjà-vu !
The project
Recently, I came across a nice-looking starry background effect on a blog at abjt.dev. I could have just copied it, but I wanted to adapt it to fit my stack, as the original was written in JS and CSS Modules, and I want to stick to TS, Tailwind and Framer Motion.
This will be the starting point of our project.
In implementing it, I made notable changes that differed from the original implementation:
- Dynamic data generation: Instead of using pre-defined data (like in the original “data.js”), I opted for dynamically generating the stars. This reduces the bundle size, which for the stars it was about 124 kb.
- Framer Motion for animations: Rather than using CSS animations (like in the original “styles.module.css”), as said above,I opted for Framer Motion. It’s easier to adjust the effect this way, based on props and state.
The Stars
Let’s start with the generateStarData
function. This function will be responsible for creating an array of random star data:
interface StarData {
size: number;
x: number;
y: number;
animationDuration: number;
}
export function generateStarData(count: number): StarData[] {
return Array.from({ length: count }, () => ({
size: Math.random() * 2 + 0.2,
x: Math.random() * 100,
y: Math.random() * 100,
animationDuration: Math.random() * 500 + 5000,
}));
}
Here’s what it does:
- We use
Array.from()
to create an array of the specified length, filling it with randomly generated star data. - For each star, we generate:
- A random
size
in pixels, between 0.1 and 2.2 - A random
x
andy
coordinates, as percentages of the container’s width and height - A random
animationDuration
between 500ms and 10000ms, representing the duration of the pulsing animation
- A random
The Background
Now, let’s look at the StarryBackground
component:
"use client"
import React, { useMemo } from 'react';
import { motion } from 'framer-motion';
import { generateStarData } from '../utils/generateStarData';
interface StarryBackgroundProps {
starCount?: number;
}
export function StarryBackground({ starCount = 100 }: StarryBackgroundProps) {
const stars = useMemo(() => generateStarData(Math.min(starCount, 1024)), [starCount]);
return (
<div className="absolute top-0 left-0 w-full h-full -z-50 pointer-events-none bg-black">
{stars.map((star, index) => (
<motion.div
key={index}
className="absolute rounded-full bg-white opacity-25"
style={{
width: star.size,
height: star.size,
top: `${star.y}%`,
left: `${star.x}%`,
}}
animate={{
opacity: [0.25, 1, 0.25],
}}
transition={{
duration: star.animationDuration / 1000,
repeat: Number.PositiveInfinity,
ease: "easeInOut",
}}
/>
))}
</div>
);
}
Here:
- As the original author did, we use
useMemo
to memoize the result ofgenerateStarData
, ensuring that we only regenerate the star data when thestarCount
changes. - We map over the
stars
array to create individual star elements:- Each star is a
motion.div
from Framer Motion, allowing us to animate it. - The star’s size and position are set using inline styles.
- We use Framer Motion’s
animate
to create a pulsing effect, changing the opacity from 0.25 to 1 and back to 0.25.
- Each star is a
The zodiac sign
Now, how the hell can we come up with a zodiac sign in code?
Well. We need to move our focus to the canvas:
- Instead of saying “we need to draw a line”, we could say we need a “SVG path”.
- Instead of saying, “we need lines to connect the stars”, we could say, “foreach constellation star unless it’s the last we need to draw a line to the next star”.
Let’s look at the relevant part of our next piece of code:
{constellation.stars.map((star, starIndex, array) => {
if (starIndex === array.length - 1) return null;
const nextStar = array[starIndex + 1];
return (
<line
key={starIndex}
x1={`${star[0]}%`}
y1={`${star[1]}%`}
x2={`${nextStar[0]}%`}
y2={`${nextStar[1]}%`}
stroke="white"
strokeWidth="0.5"
opacity="0.3"
/>
);
})}
This code is doing exactly what we described:
- It iterates over each star in the constellation.
- It checks if the current star is the last one (
if (starIndex === array.length - 1) return null;
). If it is, we don’t draw a line because there’s no “next star” to connect to. - For all other stars, it draws a line from the current star to the next star in the array.
An improved distribution
That code above works, but it doesn’t look very natural. To improve it,I won’t lie, is pretty difficult. Basically, you need to know about polar coordinates and trigonometry.
For each star in the constellation, the function will employ a polar coordinate system to distribute stars around the center point.
Then, it will calculate the angle for each star as (j / constellationSize) * Math.PI * 2
, where j
is the star’s index.
These polar coordinates are then converted to cartesian coordinates and added to the center point’s coordinates, thus creating a roughly circular arrangement.
// Generates a center point for the constellation
const centerX = Math.random() * 80 + 10; // Avoid edges
const centerY = Math.random() * 80 + 10; // Avoid edges
for (let j = 0; j < constellationSize; j++) {
// Generates stars in a circular pattern around the center
const angle = (j / constellationSize) * Math.PI * 2;
const radius = Math.random() * 15 + 5; // Desired spread
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
const star: StarData = {
size: Math.random() * 1 + 2, // Makes constellation stars slightly larger
x,
y,
animationDuration: Math.random() * 500 + 5000,
inConstellation: true,
name: starNames[selectedZodiac][j % starNames[selectedZodiac].length],
zodiacHouse: selectedZodiac,
};
stars.push(star);
constellationStars.push([x, y]);
}
Conclusion
Now that we are done, we can publish it as a component so others can use it, and the eternal cycle of “I want to copy this” can continue.
The code
Repository: github.com/feremabraz/starry-background