Skip to content

Instantly share code, notes, and snippets.

@acorn1010
Created March 8, 2023 19:43
Show Gist options
  • Save acorn1010/37533e083457a127244b2972aa594e41 to your computer and use it in GitHub Desktop.
Save acorn1010/37533e083457a127244b2972aa594e41 to your computer and use it in GitHub Desktop.
Weather / confetti component in React
import {cloneElement, CSSProperties} from 'react';
import {Helmet} from 'react-helmet';
import seedrandom from 'seedrandom';
/** Implements a weather system that displays particle effects on the page */
export type WeatherConfig = {
/** Vertical direction. (default 1, or "top to bottom") */
direction?: 1 | -1,
/** Minimum and maximum duration for particles to remain on screen. Determines vertical velocity. */
durationMs: {min: number, max: number},
/** The elements to use for particles. */
elements?: JSX.Element[],
/** Amount particle can shift horizontally [0, 1]. */
horizontalShift: {min: number, max: number},
/** Number between 0-1 of allowed opacity. (defaults to [1, 1]). */
opacity?: {min: number, max: number},
/** Number of particles to display. */
particleCount: {min: number, max: number},
/** The amount of rotation between [0, 1]. */
rotation?: {min: number, max: number},
/**
* How fast this component should spin in ms (default none). If defined, then the component will
* spin around an axis, completing one full rotation between (1 / (`min` and `max` ms)).
*/
spin?: {min: number, max: number},
/**
* Scale (size) of the particle between [0, 1]. If specified as width and height separately, then
* the aspect ratio will _not_ be maintained.
*/
scale: {min: number, max: number} | {w: {min: number, max: number}, h: {min: number, max: number}},
};
function isMinMax(value: object): value is {min: number, max: number} {
return 'min' in value && 'max' in value;
}
function getScale(random: seedrandom.prng, scale: WeatherConfig['scale']) {
if (isMinMax(scale)) {
const value = getDouble(random, scale);
return {w: value, h: value};
}
const w = getDouble(random, scale.w);
const h = getDouble(random, scale.h);
return {w, h};
}
export function Weather(config: WeatherConfig) {
const seed = useUniqueId('weather');
const random = seedrandom(seed);
const styles: {[key: string]: CSSProperties} = {};
const particleCount = getInteger(random, config.particleCount);
const particles: JSX.Element[] = [];
const direction = config.direction ?? 1;
for (let i = 1; i <= particleCount; ++i) {
const opacity = getDouble(random, config.opacity ?? {min: 1, max: 1});
const durationMs = getDouble(random, config.durationMs);
const startingPosition = getDouble(random, {min: 0, max: 100});
const secondPosition = startingPosition + getDouble(random, config.horizontalShift) * 100;
const thirdPosition = secondPosition + getDouble(random, config.horizontalShift) * 100;
const {w: scaleW, h: scaleH} = getScale(random, config.scale);
const rotation = getDouble(random, config.rotation ?? {min: 0, max: 0}) * 360;
const spin = getDouble(random, config.spin ?? {min: 0, max: 0});
const secondPositionPercent = getDouble(random, {min: 0.4, max: 0.8});
// Multiply duration by 1.5, and start at -50vh because there's a weird bug(?) where particles
// appear mid-page on load. It's weird. idk.
// const yDelta = 100 / scale; // The smaller the scale, the more we need to increase the start / stop by
const yFrom = direction === 1 ? -50 : 100;
const yTo = direction === 1 ? 100 : -50;
const animations: string[] = [`${seed}-${i} ${1.5 * durationMs / 1_000}s -3s linear infinite`];
if (spin) {
animations.push(`${seed}-${i}-spin ${1_000 / spin}s linear infinite`);
}
styles[`.falling-${seed} > *:nth-child(${i})`] = {
opacity,
transform: `rotate(${rotation}deg) scale(${scaleW}, ${scaleH})`, // randomize from 0vw to 100vw?, randomize scale from
animation: animations.join(', '), // TODO(acorn1010): Fix this? https://developer.mozilla.org/en-US/docs/Web/CSS/animation
};
styles[`@keyframes ${seed}-${i}`] = {
from: {translate: `${startingPosition}vw ${yFrom}vh`},
[`${secondPositionPercent}%`]: {
translate: `${secondPosition}vw random-yoyo-y`,
},
to: {translate: `${thirdPosition}vw ${yTo}vh`},
} as any;
if (spin) {
const xSpin = getDouble(random, {min: -1, max: 1});
const ySpin = getDouble(random, {min: -1, max: 1});
const zSpin = getDouble(random, {min: -1, max: 1});
styles[`@keyframes ${seed}-${i}-spin`] = {
from: {rotate: `${xSpin} ${ySpin} ${zSpin} 0turn`},
to: {rotate: `${xSpin} ${ySpin} ${zSpin} 1turn`},
} as any;
}
const element = sample(config.elements || []);
if (element) {
particles.push(cloneElement(element, {key: i}));
} else {
particles.push(
<div
key={i}
className='rounded-full w-[10px] h-[10px] absolute'
style={{backgroundColor: '#f0f9ff'}}
/>);
}
}
const css = cssPropertiesToStyle(styles);
return (
<div className={`fixed inset-0 top-12 z-10 pointer-events-none falling-${seed} [&>*]:absolute`}>
<Helmet><style type='text/css'>{css}</style></Helmet>
{particles}
</div>
);
}
function getInteger(random: seedrandom.prng, config: {min: number, max: number}) {
return config.min + Math.floor(random.double() * (config.max - config.min + 1));
}
function getDouble(random: seedrandom.prng, config: {min: number, max: number}) {
return config.min + random.double() * (config.max - config.min);
}
/** Converts CSS properties into a string that can be included in a style sheet. */
function cssPropertiesToStyle(css: {[key: string]: CSSProperties}, indent = 0) {
let result = '';
for (const [key, props] of Object.entries(css)) {
result += `${' '.repeat(indent)}${key} {\n`
for (const [propKey, propValue] of Object.entries(props)) {
if (typeof propValue === 'object') {
result += cssPropertiesToStyle({[propKey]: propValue}, indent + 2);
} else {
result += `${' '.repeat(indent + 2)}${propKey}: ${propValue};\n`;
}
}
result += `${' '.repeat(indent)}}\n`;
}
return result;
}
const scope = {currentId: 1};
/**
* Given an id, returns a unique variant of it. (e.g. given "foo", returns "foo-n", where n is an
* integer starting from 1.
*/
function useUniqueId(id: string) {
const [uniqueId] = useState(scope.currentId);
useEffect(() => {
scope.currentId = scope.currentId + 1; // Increment the currentId so that our unique id isn't reused.
}, []);
return `${id}-${uniqueId}`;
}
function sample<T extends unknown>(values: T[]): T | undefined {
return values[Math.floor(Math.random() * values.length)];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment