Created
March 8, 2023 19:43
-
-
Save acorn1010/37533e083457a127244b2972aa594e41 to your computer and use it in GitHub Desktop.
Weather / confetti component in React
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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