add responsive emote scaling with 118 7TV emotes, mobile support, and motion entrance

- expand emote list from 36 to 118 (xQc channel + 7TV global + trending)
- scale particle count dynamically from 12 (mobile) to 100 (4K) via container diagonal
- scale emote size (28–44px), opacity (0.25–0.35), and repulse radius per breakpoint
- enable on mobile with touch repulsion (was desktop-only)
- shuffle emotes per visit via Fisher-Yates in useEffect
- replace motion.img with plain img + motion animate() for blur+fade entrance
- move opacity to container layer for single-point control and WCAG AA safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-02-19 01:47:39 -08:00
parent 1902761945
commit 3a50e06c63
4 changed files with 390 additions and 210 deletions

View file

@ -259,7 +259,8 @@ function Carousel() {
<div
className={cn(
'flex h-full items-center justify-center',
Math.abs(offset) === 1 && 'pointer-events-auto cursor-pointer',
Math.abs(offset) === 1 &&
'pointer-events-auto cursor-pointer',
)}
{...(Math.abs(offset) === 1
? {
@ -357,7 +358,7 @@ function Carousel() {
export default function Home() {
return (
<div className='grid h-dvh overflow-hidden grid-rows-[auto_1fr] md:grid-cols-2 md:grid-rows-none'>
<div className='grid h-dvh grid-rows-[auto_1fr] overflow-hidden md:grid-cols-2 md:grid-rows-none'>
{/* Left cell — intro */}
<motion.div
className='bg-muted/50 dark:bg-muted/30 relative flex flex-col gap-4 overflow-hidden px-4'

View file

@ -23,7 +23,7 @@ function ExtLink({
href={href}
target='_blank'
rel='noreferrer'
className='text-muted-foreground underline decoration-muted-foreground/40 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/40'
className='text-muted-foreground decoration-muted-foreground/40 hover:text-foreground hover:decoration-foreground/40 underline underline-offset-4 transition-colors'
>
{children}
</a>
@ -32,10 +32,12 @@ function ExtLink({
export default function Privacy() {
return (
<div className='font-mono mx-auto max-w-2xl px-6 py-12'>
<div className='mx-auto max-w-2xl px-6 py-12 font-mono'>
<Link href='/' className='mb-12 flex items-center gap-2'>
<Image src='/logo.svg' alt='' width={24} height={24} />
<span className='text-foreground font-sans text-sm font-semibold'>Frosty</span>
<span className='text-foreground font-sans text-sm font-semibold'>
Frosty
</span>
</Link>
<h1 className='text-foreground text-2xl font-bold tracking-tight'>
@ -102,9 +104,7 @@ export default function Privacy() {
<ExtLink href='https://www.frankerfacez.com/privacy'>FFZ</ExtLink>.
</p>
<h3 className='text-foreground mt-8 text-base font-semibold'>
Firebase
</h3>
<h3 className='text-foreground mt-8 text-base font-semibold'>Firebase</h3>
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
Frosty utilizes Firebase for crash reporting, performance monitoring,
and analytics to aid in the development of new features, improvements,

View file

@ -1,11 +1,13 @@
'use client'
import { motion } from 'motion/react'
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { animate, type AnimationPlaybackControls } from 'motion'
import { useEffect, useRef } from 'react'
// ─── 7TV emotes (xQc's channel set) ─────────────────────────────────────────
// Curated from xQc's 7TV emote set — images load directly from cdn.7tv.app.
// ─── 7TV emotes ─────────────────────────────────────────────────────────────
// Curated mix of channel, global, and popular emotes.
// Images load directly from cdn.7tv.app.
const EMOTE_IDS = [
// ─── xQc channel set ───
'01FZ975PV8000B4AWRZNMVNEXN', // LOL
'01GBPSCGR00007Q17796BDN5AJ', // Classic
'01JP0FG0NN10YJPYVTZN5K67WY', // LO
@ -42,28 +44,140 @@ const EMOTE_IDS = [
'01GY1RDSR8000ENJJARE9FMTJR', // SLAY
'01FBZESCNR000A6AWCB1X558GZ', // Chatting
'01GF1Y2Q5G0000BGNJSP34TQRD', // widepeepoHappy
// ─── 7TV global emotes ───
'01FCY771D800007PQ2DF3GDTN6', // RainTime
'01FE3XY508000AA32JP519W2EW', // PETPET
'01GGD5PJA8000FH13S498E9D8X', // ppL
'01GB2TN09G000AZXHZ8HNEZX6G', // Clap2
'01GAM8EFQ00004MXFXAJYKA859', // Clap
'01GAFTZ9K80003DHH026MC7JW0', // PepePls
'01GAZ199Z8000FEWHS6AT5QZV0', // peepoHappy
'01GAZ4SBX80007YCE2RXBT44B2', // peepoSad
'01GB9W8JN80004CKF2H1TWA99H', // FeelsDankMan
'01GB2S7H7000018VJGJ4A9BMFS', // BillyApprove
'01GB8EQNJ8000497KFBZWNSDFZ', // forsenPls
'01GB54CZTG0004ZBZEDT30HE2M', // RoxyPotato
'01GB4P2HX0000BJ5HR8F6XV9Q0', // gachiBASS
'01FKSDK14G0008TM5NY9QEG0QV', // PartyParrot
'01G98W833R0000BRQD106P0ZNT', // WAYTOODANK
'01GB2ZJFBG000DTBJYANG8XYFP', // AlienDance
'01GGCQPCGR000C7MT8JZGP6E89', // ApuApustaja
'01GB9W2CDG000BFSD141G0MGSA', // BasedGod
'01G98V5RFG0001CD052SPS435F', // GuitarTime
'01HM524VE80004SKSHMCZWXH1T', // peepoPls
'01HM4P26CR000449DZBT4FVMA5', // TeaTime
'01HM4PGHC80007635TAZG67FT5', // WineTime
'01G98V81Q80000BRQD106P0ZEK', // PianoTime
'01G98TT6BR000A39K5ZSQFTPWR', // CrayonTime
'01HM6NJ2X000035ZKVAPWBNW26', // nymnCorn
'01J107C3E8000DX4MZBQSYGRXS', // sevenTV
'01FTEZEE900001E12995B12GR4', // nanaAYAYA
'01J8NMZ2HG0005G1FWF2H9Y615', // BibleThump
'01H16FA16G0005EZED5J0EY7KN', // glorp
'01GG3YGWK8000DWE419062SG28', // Stare
'01F9EM2ETG000E7SC8F953GXCX', // gachiGASM
'01GB32XE6R00018VJGJ4A9BNCV', // AYAYA
'01GB4XE3ZR000DKFRGM9Q1M7VS', // RareParrot
'01GB4FWTR8000DGEZ8VYY59RBN', // FeelsWeirdMan
'01GB4CK01800090V9B3D8CGEEX', // EZ
'01GB46137R000BJ5HR8F6XV8J1', // FeelsOkayMan
'01GB4EV0Q800090V9B3D8CGEHV', // FeelsStrongMan
'01GBFDVP18000CRDCG0DV7KEMY', // 7Cinema
// ─── Popular / trending ───
'01F6MKTFTG0009C9ZSNZTFV2ZF', // NOOOO
'01F6N31ETR0004P7N4A9PKS5X9', // BOOBA
'01F6ME7ADR0000WDA7ERT9H30R', // COPIUM
'01F5VW2TKR0003RCV2Z6JBHCST', // catKISS
'01F6MXJD8R000F76KNAAV5HDGD', // Bedge
'01F6BN89H80006VBW12DRB1DJ0', // donowall
'01F61B1440000991F7SWQNMVX7', // KEKW
'01F6RD7B88000B4N55W5NS55R7', // LETSGO
'01F6QV6G8R0000TEKRM6BFG0Z3', // ratJAM
'01GDDQVMH000038Q48APH8VE3Q', // AINTNOWAY
'01F6NET6G00009JYTB75QDKV1S', // peepoClap
'01F6NTA4X80007X1R6PNS21T6E', // peepoGiggles
'01GBFAYKGR000FWWN7MDZZ8XQN', // RAGEY
'01F6PPENA80002RDNAW6F35V4X', // YEP
'01F6NPHCN0000BEKN8ZXWQNSDC', // monkaW
'01F6NM2T080003C6R1CKK0T0P2', // WICKED
'01F6MMZW3R00012ZP6HJJ38G2E', // SadgeCry
'01G4ZTECKR0002P97QQ94BDSP4', // WHAT
'01F9Q9PA100009VDTF4G64R32V', // Nerdge
'01FYQZVG280006SX8JX4TD7SJA', // VIBE
'01GQ3WPTDR000300KQF1J7PFF8', // NOWAYING
'01H0405680000AJFXTYVX2PNJ7', // uuh
'01F6NPFQXG000AAS5FM9Q6GVCC', // 5Head
'01EZY51MDR000CYST6006V20T4', // NOPERS
'01GN5QQ1X8000C0QTR0C8JV0GR', // LETHIMCOOK
'01EZTD6KQ800012PTN006Q50PV', // Pepega
'01F6P0803G000898NRWSAKGYXT', // POGGERS
'01F2ZWD6CR000DSBG200DM9SGM', // pepeD
'01GA4TYKW0000EN50T4AGZ0CK8', // lebronJAM
'01F6M3N17G000B5V5G2M2RYJN7', // PogU
'01F9DGR2YG000E7SC8F959BREV', // RIPBOZO
'01F6NCKMP000052X5637DW2XDY', // meow
'01F6NPEJT0000B70V1XA8MNBC9', // popCat
'01F7GJ0N4R00074A83FVHRDMDB', // Okayge
'01EZPJCXQ8000C438200A44F38', // dinkDonk
'01GM78BAGR0001WBT5AMQY9YG3', // D:
'01F6Q045KR0005589X3BDQHRAY', // peepoRun
'01EZPGMA6G00047EF100A1SBTF', // TrollDespair
'01G9FN8YF000080GCW6BK847N1', // MONKE
'01F6N2GFVR000F76KNAAVCSDGX', // PauseChamp
'01F0HMZ1ZG0004V54C00A616CB', // Weirdge
'01H0SQNM9R0005HNCSM10SYJEQ', // CAUGHT
'01F6NHAQMR00015NVG2J8J00CX', // Amogus
'01F8YE5QNR00081476FRV8XDEZ', // Deadge
]
const EMOTE_URLS = EMOTE_IDS.map(
(id) => `https://cdn.7tv.app/emote/${id}/2x.webp`,
)
// ─── Physics constants ───────────────────────────────────────────────────────
const PARTICLE_COUNT = 30
const EMOTE_SIZE = 40
const TARGET_OPACITY = 0.3
// ─── Scaling ────────────────────────────────────────────────────────────────
const MAX_PARTICLES = 100
function computeParticleCount(w: number, h: number): number {
const diagonal = Math.sqrt(w * w + h * h)
return Math.max(12, Math.min(MAX_PARTICLES, Math.round(diagonal / 38)))
}
function computeEmoteSize(w: number): number {
if (w < 480) return 28
if (w < 768) return 32
if (w < 1920) return 40
return 44
}
function computeTargetOpacity(w: number): number {
// Kept low enough that emotes behind text don't break WCAG AA contrast
// (4.5:1 body text, 3:1 large text). Center zone avoidance is the main
// safeguard — emotes rarely overlap text directly.
if (w < 480) return 0.25
if (w < 768) return 0.3
return 0.35
}
function computeRepulseRadius(w: number): number {
if (w < 480) return 80
if (w < 768) return 100
return 130
}
// ─── Physics constants ──────────────────────────────────────────────────────
const INITIAL_SPEED = 0.4
const MIN_SPEED = 0.2
const MAX_SPEED = 2.5
const FRICTION = 0.998
const RESTITUTION = 0.8
const REPULSE_RADIUS = 130
const REPULSE_STRENGTH = 1.6
const CENTER_ZONE_X = 0.6 // fraction of container width
const CENTER_ZONE_Y = 0.5 // fraction of container height
const CENTER_ZONE_X = 0.6
const CENTER_ZONE_Y = 0.5
const CENTER_PUSH = 0.15
// ─── Types ───────────────────────────────────────────────────────────────────
// ─── Types ──────────────────────────────────────────────────────────────────
interface ParticleState {
x: number
y: number
@ -75,14 +189,26 @@ interface ParticleState {
measured: boolean
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Helpers ────────────────────────────────────────────────────────────────
function rand(min: number, max: number) {
return Math.random() * (max - min) + min
}
function spawnInEdgeZone(w: number, h: number): { x: number; y: number } {
// Spawn emotes in the outer ring, avoiding the center zone
const half = EMOTE_SIZE / 2
function shuffle<T>(array: T[]): T[] {
const a = [...array]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j]!, a[i]!]
}
return a
}
function spawnInEdgeZone(
w: number,
h: number,
size: number,
): { x: number; y: number } {
const half = size / 2
const zoneRx = (w * CENTER_ZONE_X) / 2
const zoneRy = (h * CENTER_ZONE_Y) / 2
let x: number
@ -94,8 +220,8 @@ function spawnInEdgeZone(w: number, h: number): { x: number; y: number } {
return { x, y }
}
function createParticle(w: number, h: number): ParticleState {
const { x, y } = spawnInEdgeZone(w, h)
function createParticle(w: number, h: number, halfW: number): ParticleState {
const { x, y } = spawnInEdgeZone(w, h, halfW * 2)
const angle = rand(0, Math.PI * 2)
return {
x,
@ -104,36 +230,16 @@ function createParticle(w: number, h: number): ParticleState {
vy: Math.sin(angle) * INITIAL_SPEED,
rotation: rand(-0.3, 0.3),
rotationSpeed: rand(-0.003, 0.003),
halfW: EMOTE_SIZE / 2,
halfW,
measured: false,
}
}
// ─── Component ───────────────────────────────────────────────────────────────
const desktopQuery = '(min-width: 768px)'
function subscribeDesktop(callback: () => void) {
const mq = window.matchMedia(desktopQuery)
mq.addEventListener('change', callback)
return () => mq.removeEventListener('change', callback)
}
function getDesktopSnapshot() {
return window.matchMedia(desktopQuery).matches
}
function getDesktopServerSnapshot() {
return false
}
// ─── Component ──────────────────────────────────────────────────────────────
export function EmotePhysicsBackground() {
const containerRef = useRef<HTMLDivElement>(null)
const isDesktop = useSyncExternalStore(
subscribeDesktop,
getDesktopSnapshot,
getDesktopServerSnapshot,
)
useEffect(() => {
if (!isDesktop) return
const container = containerRef.current
if (container === null) return
@ -141,26 +247,87 @@ export function EmotePhysicsBackground() {
container.querySelectorAll<HTMLImageElement>('[data-emote]')
if (elements.length === 0) return
// Assign shuffled URLs client-side to avoid hydration mismatch
const urls = shuffle(EMOTE_URLS)
elements.forEach((el, i) => {
if (i < urls.length) el.src = urls[i]!
})
let cancelled = false
let rafId = 0
let w = 0
let h = 0
const mouseRef = { current: null as { x: number; y: number } | null }
let activeCount = 0
let emoteSize = 40
let repulseRadius = 130
let currentPointer: { x: number; y: number } | null = null
const particles: ParticleState[] = []
const animations: AnimationPlaybackControls[] = []
// ── Sync visible particle count to container size ──
function syncCount(newW: number, newH: number, initial: boolean) {
const newCount = Math.min(
computeParticleCount(newW, newH),
elements.length,
)
const newSize = computeEmoteSize(newW)
repulseRadius = computeRepulseRadius(newW)
container!.style.opacity = String(computeTargetOpacity(newW))
emoteSize = newSize
const halfW = newSize / 2
for (const p of particles) {
if (!p.measured) p.halfW = halfW
}
if (newCount > activeCount) {
for (let i = activeCount; i < newCount; i++) {
const el = elements[i]
if (el === undefined) break
if (i >= particles.length) {
particles.push(createParticle(newW, newH, halfW))
}
el.style.display = ''
el.style.height = `${newSize}px`
if (initial) {
animations.push(
animate(
el,
{ opacity: 1, filter: ['blur(4px)', 'blur(0px)'] },
{ duration: 0.5, delay: i * 0.06, ease: 'easeOut' },
),
)
} else {
el.style.opacity = '1'
el.style.filter = ''
}
}
} else if (newCount < activeCount) {
for (let i = newCount; i < activeCount; i++) {
const el = elements[i]
if (el !== undefined) {
el.style.display = 'none'
el.style.opacity = '0'
}
}
}
// Update size on already-visible elements
for (let i = 0; i < Math.min(newCount, activeCount); i++) {
const el = elements[i]
if (el !== undefined) el.style.height = `${newSize}px`
}
activeCount = newCount
}
// Initialize dimensions
const rect = container.getBoundingClientRect()
w = rect.width
h = rect.height
if (w > 0 && h > 0) syncCount(w, h, true)
// Initialize particle states
const particles: ParticleState[] = []
elements.forEach(() => {
if (w > 0 && h > 0) {
particles.push(createParticle(w, h))
}
})
// Resize handling
// ── Resize handling ──
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry === undefined) return
@ -168,8 +335,11 @@ export function EmotePhysicsBackground() {
if (width > 0 && height > 0) {
w = width
h = height
const halfH = EMOTE_SIZE / 2
for (const p of particles) {
syncCount(w, h, false)
const halfH = emoteSize / 2
for (let i = 0; i < activeCount; i++) {
const p = particles[i]
if (p === undefined) continue
p.x = Math.max(p.halfW, Math.min(p.x, w - p.halfW))
p.y = Math.max(halfH, Math.min(p.y, h - halfH))
}
@ -177,198 +347,207 @@ export function EmotePhysicsBackground() {
})
ro.observe(container)
// Mouse tracking on the parent panel (content z-10 sits above container)
// ── Pointer tracking (mouse + touch) ──
const panel = container.parentElement ?? container
function onMouseMove(e: MouseEvent) {
function updatePointer(clientX: number, clientY: number) {
const r = container!.getBoundingClientRect()
mouseRef.current = { x: e.clientX - r.left, y: e.clientY - r.top }
currentPointer = { x: clientX - r.left, y: clientY - r.top }
}
function onMouseLeave() {
mouseRef.current = null
function clearPointer() {
currentPointer = null
}
function onMouseMove(e: MouseEvent) {
updatePointer(e.clientX, e.clientY)
}
function onTouchMove(e: TouchEvent) {
const touch = e.touches[0]
if (touch !== undefined) updatePointer(touch.clientX, touch.clientY)
}
panel.addEventListener('mousemove', onMouseMove)
panel.addEventListener('mouseleave', onMouseLeave)
panel.addEventListener('mouseleave', clearPointer)
panel.addEventListener('touchmove', onTouchMove, { passive: true })
panel.addEventListener('touchend', clearPointer)
// ── Physics loop ──
function tick() {
if (cancelled) return
const m = mouseRef.current
const centerX = w / 2
const centerY = h / 2
const zoneRadiusX = (w * CENTER_ZONE_X) / 2
const zoneRadiusY = (h * CENTER_ZONE_Y) / 2
if (activeCount > 0) {
const m = currentPointer
const centerX = w / 2
const centerY = h / 2
const zoneRadiusX = (w * CENTER_ZONE_X) / 2
const zoneRadiusY = (h * CENTER_ZONE_Y) / 2
const halfH = emoteSize / 2
// Per-particle forces, friction, speed limits, and integration
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
if (p === undefined) continue
// Per-particle forces, friction, speed limits, and integration
for (let i = 0; i < activeCount; i++) {
const p = particles[i]
if (p === undefined) continue
// Center avoidance — soft elliptical repulsion
const cdx = p.x - centerX
const cdy = p.y - centerY
const normDist = (cdx / zoneRadiusX) ** 2 + (cdy / zoneRadiusY) ** 2
if (normDist < 1 && normDist > 0.001) {
const strength = (1 - normDist) * CENTER_PUSH
const dist = Math.sqrt(cdx * cdx + cdy * cdy) || 1
p.vx += (cdx / dist) * strength
p.vy += (cdy / dist) * strength
}
// Center avoidance — soft elliptical repulsion
const cdx = p.x - centerX
const cdy = p.y - centerY
const normDist = (cdx / zoneRadiusX) ** 2 + (cdy / zoneRadiusY) ** 2
if (normDist < 1 && normDist > 0.001) {
const strength = (1 - normDist) * CENTER_PUSH
const dist = Math.sqrt(cdx * cdx + cdy * cdy) || 1
p.vx += (cdx / dist) * strength
p.vy += (cdy / dist) * strength
}
// Mouse repulsion
if (m !== null) {
const dx = p.x - m.x
const dy = p.y - m.y
const distSq = dx * dx + dy * dy
if (distSq < REPULSE_RADIUS * REPULSE_RADIUS && distSq > 0.01) {
const dist = Math.sqrt(distSq)
const force =
((REPULSE_RADIUS - dist) / REPULSE_RADIUS) * REPULSE_STRENGTH
p.vx += (dx / dist) * force
p.vy += (dy / dist) * force
// Pointer repulsion
if (m !== null) {
const dx = p.x - m.x
const dy = p.y - m.y
const distSq = dx * dx + dy * dy
if (distSq < repulseRadius * repulseRadius && distSq > 0.01) {
const dist = Math.sqrt(distSq)
const force =
((repulseRadius - dist) / repulseRadius) * REPULSE_STRENGTH
p.vx += (dx / dist) * force
p.vy += (dy / dist) * force
}
}
// Friction
p.vx *= FRICTION
p.vy *= FRICTION
// Maintain minimum speed — keeps emotes always drifting
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
if (speed < MIN_SPEED) {
if (speed > 0.001) {
const scale = MIN_SPEED / speed
p.vx *= scale
p.vy *= scale
} else {
const angle = rand(0, Math.PI * 2)
p.vx = Math.cos(angle) * MIN_SPEED
p.vy = Math.sin(angle) * MIN_SPEED
}
} else if (speed > MAX_SPEED) {
const ratio = MAX_SPEED / speed
p.vx *= ratio
p.vy *= ratio
}
// Integrate position
p.x += p.vx
p.y += p.vy
// Lazily measure actual rendered width once image loads
const el = elements[i]
if (
el !== undefined &&
!p.measured &&
el.naturalWidth > 0 &&
el.naturalHeight > 0
) {
p.halfW = ((el.naturalWidth / el.naturalHeight) * emoteSize) / 2
p.measured = true
}
}
// Friction
p.vx *= FRICTION
p.vy *= FRICTION
// Maintain minimum speed — keeps emotes always drifting
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
if (speed < MIN_SPEED) {
if (speed > 0.001) {
const scale = MIN_SPEED / speed
p.vx *= scale
p.vy *= scale
} else {
const angle = rand(0, Math.PI * 2)
p.vx = Math.cos(angle) * MIN_SPEED
p.vy = Math.sin(angle) * MIN_SPEED
}
} else if (speed > MAX_SPEED) {
const ratio = MAX_SPEED / speed
p.vx *= ratio
p.vy *= ratio
}
// Integrate position
p.x += p.vx
p.y += p.vy
// Lazily measure actual rendered width once image loads
const el = elements[i]
if (
el !== undefined &&
!p.measured &&
el.naturalWidth > 0 &&
el.naturalHeight > 0
) {
p.halfW = ((el.naturalWidth / el.naturalHeight) * EMOTE_SIZE) / 2
p.measured = true
}
}
// Particle-particle elastic collision
const halfH = EMOTE_SIZE / 2
for (let i = 0; i < particles.length; i++) {
const a = particles[i]
if (a === undefined) continue
const rA = Math.max(a.halfW, halfH)
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j]
if (b === undefined) continue
const rB = Math.max(b.halfW, halfH)
const dx = b.x - a.x
const dy = b.y - a.y
const distSq = dx * dx + dy * dy
const minDist = rA + rB
if (distSq < minDist * minDist && distSq > 0.01) {
const dist = Math.sqrt(distSq)
const nx = dx / dist
const ny = dy / dist
// Separate overlapping particles
const overlap = (minDist - dist) / 2
a.x -= nx * overlap
a.y -= ny * overlap
b.x += nx * overlap
b.y += ny * overlap
// Elastic velocity exchange along collision normal
const dvx = a.vx - b.vx
const dvy = a.vy - b.vy
const dvDotN = dvx * nx + dvy * ny
if (dvDotN > 0) {
a.vx -= dvDotN * nx * RESTITUTION
a.vy -= dvDotN * ny * RESTITUTION
b.vx += dvDotN * nx * RESTITUTION
b.vy += dvDotN * ny * RESTITUTION
// Particle-particle elastic collision
for (let i = 0; i < activeCount; i++) {
const a = particles[i]
if (a === undefined) continue
const rA = Math.max(a.halfW, halfH)
for (let j = i + 1; j < activeCount; j++) {
const b = particles[j]
if (b === undefined) continue
const rB = Math.max(b.halfW, halfH)
const dx = b.x - a.x
const dy = b.y - a.y
const distSq = dx * dx + dy * dy
const minDist = rA + rB
if (distSq < minDist * minDist && distSq > 0.01) {
const dist = Math.sqrt(distSq)
const nx = dx / dist
const ny = dy / dist
// Separate overlapping particles
const overlap = (minDist - dist) / 2
a.x -= nx * overlap
a.y -= ny * overlap
b.x += nx * overlap
b.y += ny * overlap
// Elastic velocity exchange along collision normal
const dvx = a.vx - b.vx
const dvy = a.vy - b.vy
const dvDotN = dvx * nx + dvy * ny
if (dvDotN > 0) {
a.vx -= dvDotN * nx * RESTITUTION
a.vy -= dvDotN * ny * RESTITUTION
b.vx += dvDotN * nx * RESTITUTION
b.vy += dvDotN * ny * RESTITUTION
}
}
}
}
}
// Wall bounce and DOM write
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
const el = elements[i]
if (p === undefined || el === undefined) continue
// Wall bounce and DOM write
for (let i = 0; i < activeCount; i++) {
const p = particles[i]
const el = elements[i]
if (p === undefined || el === undefined) continue
if (p.x - p.halfW < 0) {
p.x = p.halfW
p.vx = Math.abs(p.vx) * RESTITUTION
} else if (p.x + p.halfW > w) {
p.x = w - p.halfW
p.vx = -Math.abs(p.vx) * RESTITUTION
if (p.x - p.halfW < 0) {
p.x = p.halfW
p.vx = Math.abs(p.vx) * RESTITUTION
} else if (p.x + p.halfW > w) {
p.x = w - p.halfW
p.vx = -Math.abs(p.vx) * RESTITUTION
}
if (p.y - halfH < 0) {
p.y = halfH
p.vy = Math.abs(p.vy) * RESTITUTION
} else if (p.y + halfH > h) {
p.y = h - halfH
p.vy = -Math.abs(p.vy) * RESTITUTION
}
// Rotation
p.rotation += p.rotationSpeed
// Write to DOM — only transform, no layout thrash
el.style.transform = `translate3d(${p.x - p.halfW}px, ${p.y - halfH}px, 0) rotate(${p.rotation}rad)`
}
if (p.y - halfH < 0) {
p.y = halfH
p.vy = Math.abs(p.vy) * RESTITUTION
} else if (p.y + halfH > h) {
p.y = h - halfH
p.vy = -Math.abs(p.vy) * RESTITUTION
}
// Rotation
p.rotation += p.rotationSpeed
// Write to DOM — only transform, no layout thrash
el.style.transform = `translate3d(${p.x - p.halfW}px, ${p.y - halfH}px, 0) rotate(${p.rotation}rad)`
}
rafId = requestAnimationFrame(tick)
}
// Start loop only if we have particles
if (particles.length > 0) {
tick()
}
tick()
return () => {
cancelled = true
cancelAnimationFrame(rafId)
for (const a of animations) a.stop()
ro.disconnect()
panel.removeEventListener('mousemove', onMouseMove)
panel.removeEventListener('mouseleave', onMouseLeave)
panel.removeEventListener('mouseleave', clearPointer)
panel.removeEventListener('touchmove', onTouchMove)
panel.removeEventListener('touchend', clearPointer)
}
}, [isDesktop])
if (!isDesktop) return null
}, [])
return (
<div ref={containerRef} className='absolute inset-0 overflow-hidden'>
{EMOTE_URLS.slice(0, PARTICLE_COUNT).map((url, i) => (
<motion.img
key={url}
{Array.from({ length: MAX_PARTICLES }, (_, i) => (
// eslint-disable-next-line @next/next/no-img-element -- imperative DOM manipulation requires plain <img>
<img
key={i}
data-emote
src={url}
alt=''
draggable={false}
crossOrigin='anonymous'
initial={{ opacity: 0 }}
animate={{ opacity: TARGET_OPACITY }}
transition={{ duration: 0.6, delay: i * 0.04, ease: 'easeOut' }}
className='pointer-events-none absolute top-0 left-0 select-none'
style={{
height: EMOTE_SIZE,
willChange: 'transform',
display: 'none',
opacity: 0,
filter: 'blur(4px)',
}}
/>
))}

View file

@ -9,7 +9,7 @@ export function Footer() {
href={tommyLink}
target='_blank'
rel='noreferrer'
className='underline underline-offset-4 transition-colors hover:text-muted-foreground'
className='hover:text-muted-foreground underline underline-offset-4 transition-colors'
>
Tommy Chow
</a>