diff --git a/app/components/Landing/IntroHeader.vue b/app/components/Landing/IntroHeader.vue
index 00a450c2c5..d3c4444004 100644
--- a/app/components/Landing/IntroHeader.vue
+++ b/app/components/Landing/IntroHeader.vue
@@ -126,7 +126,7 @@ onPrehydrate(el => {
/>
diff --git a/app/components/Noodle/EmojiDay/Logo.vue b/app/components/Noodle/EmojiDay/Logo.vue
new file mode 100644
index 0000000000..ee3215d3f4
--- /dev/null
+++ b/app/components/Noodle/EmojiDay/Logo.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
diff --git a/app/components/Noodle/EmojiDay/ThemedLogo.vue b/app/components/Noodle/EmojiDay/ThemedLogo.vue
new file mode 100644
index 0000000000..a790ad514f
--- /dev/null
+++ b/app/components/Noodle/EmojiDay/ThemedLogo.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
diff --git a/app/components/Noodle/EmojiDay/emoji-thing.js b/app/components/Noodle/EmojiDay/emoji-thing.js
new file mode 100644
index 0000000000..3b398e6894
--- /dev/null
+++ b/app/components/Noodle/EmojiDay/emoji-thing.js
@@ -0,0 +1,454 @@
+const VIEW_WIDTH = 900
+const VIEW_HEIGHT = 360
+const POINTER_REPULSION_DISTANCE = 100
+const POINTER_REPULSION_FACTOR = 1.0
+const VELOCITY_DAMPENING_FACTOR = 0.92
+// Increase to push emojis further away from each other.
+const COLLISION_MARGIN = 1
+// The minimum x- or y-distance we bother to translate a sprite, scaled to
+// the current view. Allows skipping sprite.style.translation = "..." assignments.
+const MIN_SPRITE_MOVEMENT = 0.25
+
+// Each glyph is built from 1-n parts.
+// Each part has an associated set of 1-m possible emojis and a polyline path.
+//
+// The assumption is that there are no empty paths, zero length segments or
+// other such shenanigans.
+const GLYPH_CONFIG = [
+ // dot
+ {
+ emojiSize: 48,
+ parts: [
+ {
+ emojiSet: 'hearts',
+ emojiCount: 1,
+ path: polyline(24, 254, 25, 254),
+ },
+ ],
+ },
+ // slash
+ {
+ emojiSize: 36,
+ parts: [
+ {
+ emojiSet: 'faces',
+ emojiCount: 8,
+ path: polyline(80, 320, 170, 18),
+ },
+ ],
+ },
+ // n
+ {
+ emojiSize: 20,
+ parts: [
+ {
+ emojiSet: 'green',
+ emojiCount: 19,
+ path: polyline(216, 260, 216, 125, 206, 120),
+ },
+ {
+ emojiSet: 'green',
+ emojiCount: 24,
+ path: polyline(233, 155, 241, 140, 269, 125, 300, 137, 322, 153, 322, 259),
+ },
+ ],
+ },
+ // p
+ {
+ emojiSize: 20,
+ parts: [
+ {
+ emojiSet: 'gold',
+ emojiCount: 22,
+ path: polyline(384, 115, 384, 330),
+ },
+ {
+ emojiSet: 'gold',
+ emojiCount: 25,
+ path: polyline(404, 135, 434, 117, 476, 133, 496, 179, 478, 220, 437, 238, 394, 232),
+ },
+ ],
+ },
+ // m
+ {
+ emojiSize: 20,
+ parts: [
+ {
+ emojiSet: 'earth',
+ emojiCount: 17,
+ path: polyline(550, 257, 550, 125, 540, 115),
+ },
+ {
+ emojiSet: 'earth',
+ emojiCount: 18,
+ path: polyline(569, 130, 592, 123, 613, 136, 628, 168, 628, 257),
+ },
+ {
+ emojiSet: 'themed',
+ emojiCount: 24,
+ path: polyline(628, 155, 649, 129, 673, 123, 695, 136, 710, 168, 710, 257),
+ },
+ ],
+ },
+ // x
+ {
+ emojiSize: 20,
+ parts: [
+ {
+ emojiSet: 'blue',
+ emojiCount: 21,
+ path: polyline(760, 118, 820, 184, 880, 118),
+ },
+ {
+ emojiSet: 'red',
+ emojiCount: 21,
+ path: polyline(760, 259, 820, 194, 880, 259),
+ },
+ ],
+ },
+]
+
+export function init(sizer, emojiSetImages) {
+ const area = document.createElement('div')
+
+ Object.assign(area.style, {
+ 'display': 'block',
+ 'position': 'absolute',
+ 'width': `${VIEW_WIDTH}px`,
+ 'height': `${VIEW_HEIGHT}px`,
+ 'transform-origin': '0 0',
+ })
+ sizer.appendChild(area)
+
+ const resizeObserver = new ResizeObserver(() => {
+ const rect = sizer.getBoundingClientRect()
+ const scale = rect.width / VIEW_WIDTH
+ if (scale < 1) {
+ area.style.transform = `scale(${scale}, ${scale})`
+ } else {
+ area.style.transform = ''
+ }
+ })
+ resizeObserver.observe(sizer)
+
+ let pointer = undefined
+ function onPointerMove(event) {
+ const rect = area.getBoundingClientRect()
+ pointer = {
+ x: ((event.clientX - rect.left) / rect.width) * VIEW_WIDTH,
+ y: ((event.clientY - rect.top) / rect.height) * VIEW_HEIGHT,
+ }
+ }
+ area.addEventListener('pointerenter', onPointerMove)
+ area.addEventListener('pointermove', onPointerMove)
+ area.addEventListener('pointerout', () => {
+ pointer = undefined
+ })
+
+ const emojiSets = new Map(
+ Object.entries(emojiSetImages).map(([name, image]) => {
+ const width = image.width
+ const count = Math.floor(image.height / width)
+ const indexes = shuffle(Array.from(Array(count), (_, i) => i))
+
+ return [
+ name,
+ {
+ createSprite(size) {
+ const index = indexes.shift()
+ indexes.push(index)
+
+ // Render emoji at double resolution.
+ const realSize = Math.min(2 * size, width)
+
+ const sprite = document.createElement('canvas')
+ sprite.width = realSize
+ sprite.height = realSize
+ Object.assign(sprite.style, {
+ position: 'absolute',
+ left: `-${size / 2}px`,
+ top: `-${size / 2}px`,
+ width: `${size}px`,
+ height: `${size}px`,
+ })
+
+ const ctx = sprite.getContext('2d')
+ ctx.drawImage(image, 0, width * index, width, width, 0, 0, realSize, realSize)
+
+ return sprite
+ },
+ },
+ ]
+ }),
+ )
+
+ const glyphs = GLYPH_CONFIG.map(config => ({
+ config: config,
+ parts: config.parts.map(part => {
+ const emoji = []
+ for (let i = 0; i < part.emojiCount; i++) {
+ const x = VIEW_WIDTH / 2
+ const y = VIEW_HEIGHT / 2
+
+ const sprite = emojiSets.get(part.emojiSet).createSprite(config.emojiSize)
+ sprite.style.transform = `translate3d(${x}px, ${y}px, 0)`
+ area.appendChild(sprite)
+
+ emoji.push({
+ sprite,
+
+ // Emoji's current position.
+ x,
+ y,
+
+ // Emoji sprite's current translation.
+ sx: x,
+ sy: y,
+
+ // Emoji's current velocity.
+ // Dampened by VELOCITY_DAMPENING_FACTOR * dampFactor on each frame.
+ vx: (Math.random() - 0.5) * VIEW_WIDTH * 0.5,
+ vy: (Math.random() - 0.5) * VIEW_HEIGHT * 0.5,
+
+ // The current forces affecting this emoji.
+ // Zeroed at the beginning of each frame.
+ fx: 0,
+ fy: 0,
+
+ // If there are no significant forces affecting this emoji,
+ // then decrease this to avoid micro-jitters.
+ dampFactor: 1.0,
+ })
+ }
+
+ const totalLength = part.path.reduce((total, segment) => total + segment.length, 0)
+ let startsAtLength = 0
+ return {
+ emoji,
+ totalLength,
+ path: part.path.map(segment => {
+ const result = { ...segment, startsAtLength }
+ startsAtLength += segment.length
+ return result
+ }),
+ }
+ }),
+ }))
+
+ let prevNow
+ let animationFrame
+
+ function render(now) {
+ // This is coded on a 120Hz display and I'm lazy.
+ const timeStep = clamp((now - (prevNow ?? now)) / 8.3333, -1, 10)
+ prevNow = now
+
+ for (const glyph of glyphs) {
+ for (const part of glyph.parts) {
+ for (const emoji of part.emoji) {
+ emoji.fx = 0
+ emoji.fy = 0
+ }
+
+ // Figure out where each emoji would be on the path if they were spread equally
+ // based on their closest point on the path. Pull each emoji towards this position.
+ part.emoji
+ .map(emoji => ({
+ t: projectPoint(part, emoji),
+ emoji,
+ }))
+ .sort((a, b) => a.t - b.t)
+ .forEach(({ t, emoji }, index) => {
+ const point = pointAt(part, index / Math.max(part.emoji.length - 1, 2))
+ emoji.fx += point.x - emoji.x
+ emoji.fy += point.y - emoji.y
+
+ // Also pull each emoji a bit towards the current closest point on the path. As a treat.
+ const p = pointAt(part, t)
+ emoji.fx += (p.x - emoji.x) * 0.5
+ emoji.fy += (p.y - emoji.y) * 0.5
+ })
+ }
+
+ // All emojis in a glyph repel each other if they're too close.
+ const collisionSize = glyph.config.emojiSize + COLLISION_MARGIN
+ const glyphEmojis = glyph.parts.flatMap(part => part.emoji)
+ for (let i = 0; i < glyphEmojis.length; i++) {
+ const a = glyphEmojis[i]
+
+ for (let j = i + 1; j < glyphEmojis.length; j++) {
+ const b = glyphEmojis[j]
+ let dx = b.x - a.x
+ let dy = b.y - a.y
+ let d = Math.sqrt(dx ** 2 + dy ** 2)
+ while (d < Number.EPSILON) {
+ dx = Math.random() - 0.5
+ dy = Math.random() - 0.5
+ d = Math.sqrt(dx ** 2 + dy ** 2)
+ }
+
+ const f = 5 * Math.max(collisionSize - d, 0)
+ a.fx += (-dx * f) / d
+ a.fy += (-dy * f) / d
+ b.fx += (dx * f) / d
+ b.fy += (dy * f) / d
+ }
+ }
+
+ // Push emojis away from the pointer.
+ if (pointer) {
+ const rect = area.getBoundingClientRect()
+ const repulsionDistance = POINTER_REPULSION_DISTANCE / (rect.width / VIEW_WIDTH) ** 0.5
+
+ for (const emoji of glyphEmojis) {
+ let dx = emoji.x - pointer.x
+ let dy = emoji.y - pointer.y
+ let d = Math.sqrt(dx ** 2 + dy ** 2)
+ while (d < Number.EPSILON) {
+ dx = Math.random() - 0.5
+ dy = Math.random() - 0.5
+ d = Math.sqrt(dx ** 2 + dy ** 2)
+ }
+
+ const f = Math.max(repulsionDistance - d, 0) * POINTER_REPULSION_FACTOR
+ emoji.fx += (dx / d) * f
+ emoji.fy += (dy / d) * f
+ }
+ }
+
+ for (const emoji of glyphEmojis) {
+ // If there are no significant enough forces affecting the emoji,
+ // then let it settle down a bit.
+ if (emoji.fx ** 2 + emoji.fy ** 2 < 10) {
+ emoji.dampFactor *= 0.95 ** timeStep
+ } else {
+ emoji.dampFactor = 1
+ }
+
+ emoji.vx += emoji.fx * 0.05 * timeStep
+ emoji.vy += emoji.fy * 0.05 * timeStep
+ emoji.vx *= (VELOCITY_DAMPENING_FACTOR * emoji.dampFactor) ** timeStep
+ emoji.vy *= (VELOCITY_DAMPENING_FACTOR * emoji.dampFactor) ** timeStep
+
+ emoji.x += emoji.vx * 0.1 * timeStep
+ emoji.y += emoji.vy * 0.1 * timeStep
+ }
+ }
+
+ const { width, height } = area.getBoundingClientRect()
+ const minX = (MIN_SPRITE_MOVEMENT * VIEW_WIDTH) / width / window.devicePixelRatio
+ const minY = (MIN_SPRITE_MOVEMENT * VIEW_HEIGHT) / height / window.devicePixelRatio
+ for (const glyph of glyphs) {
+ for (const part of glyph.parts) {
+ for (const emoji of part.emoji) {
+ const { x, y, sx, sy, sprite } = emoji
+ if (Math.abs(sx - x) >= minX || Math.abs(sy - y) >= minY) {
+ emoji.sx = x
+ emoji.sy = y
+ sprite.style.transform = `translate3d(${x}px, ${y}px, 0)`
+ }
+ }
+ }
+ }
+
+ animationFrame = requestAnimationFrame(render)
+ }
+
+ return {
+ start() {
+ if (animationFrame === undefined) {
+ prevNow = undefined
+ render(document.timeline?.currentTime ?? 0)
+ }
+ },
+ pause() {
+ if (animationFrame !== undefined) {
+ cancelAnimationFrame(animationFrame)
+ animationFrame = undefined
+ }
+ },
+ destroy() {
+ this.pause()
+
+ resizeObserver.disconnect()
+ area.remove()
+ },
+ }
+}
+
+function clamp(x, min, max) {
+ if (x < min) {
+ return min
+ } else if (x > max) {
+ return max
+ } else {
+ return x
+ }
+}
+
+function shuffle(array) {
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ const tmp = array[j]
+ array[j] = array[i]
+ array[i] = tmp
+ }
+ return array
+}
+
+function polyline(x, y, ...coords) {
+ let x0 = x
+ let y0 = y
+
+ const path = []
+ for (let i = 0; i < coords.length; i += 2) {
+ let x1 = coords[i]
+ let y1 = coords[i + 1]
+ const dx = x1 - x0
+ const dy = y1 - y0
+ path.push({ x0, y0, x1, y1, dx, dy, length: Math.sqrt(dx ** 2 + dy ** 2) })
+ x0 = x1
+ y0 = y1
+ }
+ return path
+}
+
+function pointAt(part, t) {
+ const targetLength = part.totalLength * t
+
+ let lo = 0
+ let hi = part.path.length
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1
+ const segment = part.path[mid]
+ if (segment.startsAtLength < targetLength) {
+ lo = mid + 1
+ } else {
+ hi = mid
+ }
+ }
+
+ const segment = part.path[Math.max(lo - 1, 0)]
+ const st = (targetLength - segment.startsAtLength) / segment.length
+ return {
+ x: segment.x0 + segment.dx * st,
+ y: segment.y0 + segment.dy * st,
+ }
+}
+
+function projectPoint(part, { x, y }) {
+ let smallestDistance = Infinity
+ let result = 0
+
+ for (const { x0, y0, dx, dy, length, startsAtLength } of part.path) {
+ const dot = (x - x0) * dx + (y - y0) * dy
+ const t = clamp(dot / length ** 2, 0, 1)
+ const distance = Math.sqrt((x - (x0 + dx * t)) ** 2 + (y - (y0 + dy * t)) ** 2)
+ if (distance < smallestDistance) {
+ result = (t * length + startsAtLength) / part.totalLength
+ smallestDistance = distance
+ }
+ }
+
+ return result
+}
diff --git a/app/components/Noodle/EmojiDay/emojisets/blue.png b/app/components/Noodle/EmojiDay/emojisets/blue.png
new file mode 100644
index 0000000000..7022972802
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/blue.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/earth.png b/app/components/Noodle/EmojiDay/emojisets/earth.png
new file mode 100644
index 0000000000..605d68efbc
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/earth.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/faces.png b/app/components/Noodle/EmojiDay/emojisets/faces.png
new file mode 100644
index 0000000000..fe817e581b
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/faces.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/gold.png b/app/components/Noodle/EmojiDay/emojisets/gold.png
new file mode 100644
index 0000000000..4d7ad51e35
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/gold.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/green.png b/app/components/Noodle/EmojiDay/emojisets/green.png
new file mode 100644
index 0000000000..14dabffd96
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/green.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/hearts.png b/app/components/Noodle/EmojiDay/emojisets/hearts.png
new file mode 100644
index 0000000000..c318997d76
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/hearts.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/red.png b/app/components/Noodle/EmojiDay/emojisets/red.png
new file mode 100644
index 0000000000..660ec910a9
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/red.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/themed-dark.png b/app/components/Noodle/EmojiDay/emojisets/themed-dark.png
new file mode 100644
index 0000000000..a3f6a5fc38
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/themed-dark.png differ
diff --git a/app/components/Noodle/EmojiDay/emojisets/themed-light.png b/app/components/Noodle/EmojiDay/emojisets/themed-light.png
new file mode 100644
index 0000000000..edba4db465
Binary files /dev/null and b/app/components/Noodle/EmojiDay/emojisets/themed-light.png differ
diff --git a/app/components/Noodle/index.ts b/app/components/Noodle/index.ts
index 7bfd792578..9e611daf54 100644
--- a/app/components/Noodle/index.ts
+++ b/app/components/Noodle/index.ts
@@ -8,6 +8,7 @@ import NoodleTransgenderVisibilityLogo from './TransgenderVisibility/Logo.vue'
import NoodlePride2Logo from './Pride2/Logo.vue'
import NoodlePride3Logo from './Pride3/Logo.vue'
import NoodleTetrisLogo from './Tetris/Logo.vue'
+import NoodleEmojiDayLogo from './EmojiDay/Logo.vue'
export type Noodle = {
// Unique identifier for the noodle
@@ -64,6 +65,13 @@ export const ACTIVE_NOODLES: Noodle[] = [
dateTo: '2026-07-01',
timezone: 'auto',
},
+ {
+ key: 'emoji-day',
+ logo: NoodleEmojiDayLogo,
+ date: '2026-07-17',
+ dateTo: '2026-07-19',
+ timezone: 'auto',
+ },
]
// Logo registry for the /noodles archive, keyed by the entry's `key` in
@@ -77,6 +85,7 @@ const NOODLE_LOGOS: Record = {
'nodejs': NoodleNodejsLogo,
'pride-1': NoodlePride1Logo,
'tetris': NoodleTetrisLogo,
+ 'emoji-day': NoodleEmojiDayLogo,
}
export function resolveNoodleLogo(key: string): Component | undefined {
diff --git a/app/noodles.ts b/app/noodles.ts
index 5a376d9bb9..e759d6f8d4 100644
--- a/app/noodles.ts
+++ b/app/noodles.ts
@@ -6,6 +6,8 @@ import type { Noodle } from '#shared/schemas/noodle'
const ALEX = { name: 'Alex Savelyev', blueskyHandle: 'alexdln.com' }
const ALFON = { name: 'Alfon', blueskyHandle: 'alfon.dev' }
const GRAPHIEROS = { name: 'Graphieros', blueskyHandle: 'graphieros.npmx.social' }
+const FELIX = { name: 'Felix Schneider', blueskyHandle: 'felixs.dev' }
+const JVIIDE = { name: 'Joachim Viide', blueskyHandle: 'jviide.iki.fi' }
const entries: Noodle[] = [
{
@@ -133,6 +135,22 @@ const entries: Noodle[] = [
},
],
},
+ {
+ key: 'emoji-day',
+ title: 'World Emoji Day',
+ slug: 'emoji-day',
+ date: '2026-07-17',
+ dateTo: '2026-07-19',
+ timezone: 'auto',
+ tagline: false,
+ occasion: '📅🌍🚀💬🥳✨',
+ prUrl: 'https://github.com/npmx-dev/npmx.dev/pull/3038',
+ authors: [FELIX, JVIIDE],
+ references: [
+ { label: 'World Emoji Day Website', url: 'https://worldemojiday.com/' },
+ { label: 'Wikipedia', url: 'https://en.wikipedia.org/wiki/World_Emoji_Day' },
+ ],
+ },
]
export const noodles: Noodle[] = [...entries].sort(
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index a44ef8acf2..d9e4412fde 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -159,6 +159,8 @@ import {
ButtonBase,
LandingIntroHeader,
NoodleArtemisLogo,
+ NoodleEmojiDayLogo,
+ NoodleEmojiDayThemedLogo,
NoodleKawaiiLogo,
NoodleTransgenderVisibilityLogo,
NoodleListCard,
@@ -412,6 +414,22 @@ describe('component accessibility audits', () => {
expect(results.violations).toEqual([])
})
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(NoodleEmojiDayLogo)
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(NoodleEmojiDayThemedLogo, {
+ props: {
+ emojiSets: {},
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
it('should have no accessibility violations', async () => {
const component = await mountSuspended(NoodlePride1Logo)
const results = await runAxe(component)