diff --git a/app/components/Common/Confetti.vue b/app/components/Common/Confetti.vue
new file mode 100644
index 0000000..13c9dc3
--- /dev/null
+++ b/app/components/Common/Confetti.vue
@@ -0,0 +1,34 @@
+
diff --git a/app/composables/useConfetti.ts b/app/composables/useConfetti.ts
new file mode 100644
index 0000000..368dfa9
--- /dev/null
+++ b/app/composables/useConfetti.ts
@@ -0,0 +1,109 @@
+const PARTICLE_COUNT = 400;
+const SPAWN_DURATION = 700;
+const VX_RANGE = 0.5;
+const VY_MIN = 0.3;
+const VY_RANGE = 0.2;
+const WIDTH_MIN = 3;
+const WIDTH_RANGE = 4;
+const HEIGHT_MIN = 2;
+const HEIGHT_RANGE = 3;
+const ROTATION_SPEED_RANGE = 0.15;
+const DRIFT_STRENGTH = 0.03;
+const GRAVITY = 0.007;
+const VX_DAMPING = 0.995;
+const SWAY_AMPLITUDE = 0.3;
+const SWAY_SPEED_MIN = 0.02;
+const SWAY_SPEED_RANGE = 0.03;
+const OFFSCREEN_MARGIN = 10;
+const TOTAL_HEIGHT = 192 * 2;
+
+const COLORS = [
+ "#fb0018",
+ "#0059f3",
+ "#f3e300",
+ "#00fb00",
+ "#fb8afb",
+ "#fb9200",
+];
+
+export type ConfettiParticle = {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ width: number;
+ height: number;
+ color: string;
+ rotation: number;
+ rotationSpeed: number;
+ swayPhase: number;
+ swaySpeed: number;
+};
+
+type Spawner = {
+ frame: number;
+ accumulator: number;
+};
+
+const particles: ConfettiParticle[] = [];
+const spawners: Spawner[] = [];
+
+const spawnParticle = () => {
+ particles.push({
+ x: Math.random() * LOGICAL_WIDTH,
+ y: -(30 + Math.random() * 20),
+ vx: (Math.random() - 0.5) * VX_RANGE,
+ vy: VY_MIN + Math.random() * VY_RANGE,
+ width: WIDTH_MIN + Math.random() * WIDTH_RANGE,
+ height: HEIGHT_MIN + Math.random() * HEIGHT_RANGE,
+ color: COLORS[Math.floor(Math.random() * COLORS.length)]!,
+ rotation: Math.random() * Math.PI * 2,
+ rotationSpeed: (Math.random() - 0.5) * ROTATION_SPEED_RANGE,
+ swayPhase: Math.random() * Math.PI * 2,
+ swaySpeed: SWAY_SPEED_MIN + Math.random() * SWAY_SPEED_RANGE,
+ });
+};
+
+const spawn = () => {
+ spawners.push({ frame: 0, accumulator: 0 });
+};
+
+const update = () => {
+ for (let s = spawners.length - 1; s >= 0; s -= 1) {
+ const spawner = spawners[s]!;
+ if (spawner.frame < SPAWN_DURATION) {
+ const progress = spawner.frame / SPAWN_DURATION;
+ const inv = 1 - progress;
+ const rate = inv * inv;
+ spawner.accumulator += rate * (PARTICLE_COUNT / SPAWN_DURATION) * 3;
+
+ const count = Math.floor(spawner.accumulator);
+ spawner.accumulator -= count;
+
+ for (let i = 0; i < count; i += 1) {
+ spawnParticle();
+ }
+
+ spawner.frame += 1;
+ } else {
+ spawners.splice(s, 1);
+ }
+ }
+
+ for (let i = particles.length - 1; i >= 0; i -= 1) {
+ const p = particles[i]!;
+ p.vy += GRAVITY;
+ p.vx += (Math.random() - 0.5) * DRIFT_STRENGTH;
+ p.vx *= VX_DAMPING;
+ p.swayPhase += p.swaySpeed;
+ p.x += p.vx + Math.sin(p.swayPhase) * SWAY_AMPLITUDE;
+ p.y += p.vy;
+ p.rotation += p.rotationSpeed;
+
+ if (p.y > TOTAL_HEIGHT + OFFSCREEN_MARGIN) {
+ particles.splice(i, 1);
+ }
+ }
+};
+
+export const useConfetti = () => ({ particles, spawn, update });
diff --git a/app/pages/index.vue b/app/pages/index.vue
index b9432c7..eee0cc0 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -103,6 +103,7 @@ useKeyUp((key) => {