Skip to content
· 6 min read · 0 views

Zoro Santoryu Splash Screen — 30 Days Web Challenge Day 4

A cinematic splash screen featuring pixel art Zoro running across the screen, then unleashing Santoryu — three sword slashes that shatter the page with screen shake, debris particles, and music transitions.

// table of contents (10 sections)

Try it live at 30days.abduarrahman.com and the source code is on GitHub.


Live Demo

Tap to start, watch Zoro run, then unleash Santoryu — three slashes that shatter the white screen and reveal the challenge beneath:


The Origin

Day 4 needed a splash screen. A proper, cinematic intro — not just a loading spinner. I’m a One Piece fan, and Zoro’s Santoryu (Three-Sword Style) is iconic. What if the splash screen was Zoro running, then slashing the screen three times until it shatters?

The result: a multi-phase splash screen with pixel art Zoro running, a loading bar, a dramatic Santoryu reveal, three timed slashes with screen shake, and a shatter effect that breaks the screen into 9 fragments.


What I Built

A cinematic splash screen with 5 phases:

  1. Tap to Start — Pixel art Zoro running animation on a clean white screen
  2. Loading — Progress bar fills over 5 seconds while Zoro keeps running, ambient music plays
  3. Santoryu — Loading music fades out, dramatic Santoryu image zooms in with impact
  4. Slash — Three timed sword slashes with blade tips, scar lines, sparks, and screen shake
  5. Shatter — Screen breaks into 9 fragments that fly apart with debris particles

All powered by a single ZoroPixelLoader canvas component and Framer Motion for phase transitions.


How It Works

Canvas Sprite Animator

The ZoroPixelLoader renders pixel art sprite sheets onto a canvas with frame-by-frame animation:

const SPRITES: Record<string, SpriteConfig> = {
  run:   { src: "/zoro_run.png", frameW: 445, frameH: 363 },
  slash: { src: "/zoro.png",     frameW: 290, frameH: 349 },
};

export default function ZoroPixelLoader({ sprite = "run", fps = 14, scale = 0.5 }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const frameRef = useRef(0);
  const rafRef = useRef<number>(0);
  const lastTimeRef = useRef(0);
  const config = SPRITES[sprite];
  const fpsInterval = 1000 / fps;

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const img = new Image();
    img.src = config.src;

    const animate = (time: number) => {
      const delta = time - lastTimeRef.current;
      if (delta >= fpsInterval) {
        lastTimeRef.current = time - (delta % fpsInterval);
        const frame = frameRef.current;
        const col = frame % COLS;
        const row = Math.floor(frame / COLS);

        ctx!.clearRect(0, 0, config.frameW, config.frameH);
        ctx!.drawImage(
          img,
          col * config.frameW, row * config.frameH,
          config.frameW, config.frameH,
          0, 0, config.frameW, config.frameH
        );
        frameRef.current = (frame + 1) % TOTAL_FRAMES;
      }
      rafRef.current = requestAnimationFrame(animate);
    };

    img.onload = () => {
      ctx!.imageSmoothingEnabled = false; // crisp pixel art
      rafRef.current = requestAnimationFrame(animate);
    };

    return () => cancelAnimationFrame(rafRef.current);
  }, [sprite, config, fpsInterval]);

  return (
    <canvas
      ref={canvasRef}
      width={config.frameW}
      height={config.frameH}
      style={{ imageRendering: "pixelated", width: config.frameW * scale, height: config.frameH * scale }}
    />
  );
}

The key detail: imageSmoothingEnabled = false and imageRendering: "pixelated" keep the pixel art crisp at any scale.

Slash Sequence Timing

Three slashes are precisely timed using constants:

const SLASH_SOUND_DUR = 576;    // ms — matches the slash sound effect length
const NUM_SLASHES = 3;
const SLASH_INTERVAL = SLASH_SOUND_DUR;  // each slash starts after previous sound ends
const TOTAL_SLASH_TIME = NUM_SLASHES * SLASH_INTERVAL;
const SANTORYU_DUR = 2736;      // ms — duration of the Santoryu vocal sample

const SLASHES = [
  { angle: -30, delay: 0 },
  { angle: 15,  delay: SLASH_INTERVAL },
  { angle: -50, delay: SLASH_INTERVAL * 2 },
];

Each slash has a blade tip that sweeps across, a persistent scar line, sparks, and a white flash:

{/* Blade tip — sweeps across */}
<motion.div
  style={{
    width: "30%", height: "6px",
    background: "linear-gradient(90deg, transparent, #444 20%, #000 50%, #444 80%, transparent)",
    transform: `rotate(${s.angle}deg)`,
  }}
  initial={{ x: "-150%", opacity: 0 }}
  animate={{ x: "500%", opacity: [0, 1, 1, 0] }}
  transition={{ duration: 0.35, delay: s.delay / 1000 }}
/>

{/* Scar line — stays visible */}
<motion.div
  style={{
    width: "120%", height: "6px", background: "#000",
    transform: `rotate(${s.angle}deg)`,
  }}
  initial={{ scaleX: 0, opacity: 0 }}
  animate={{ scaleX: 1, opacity: 1 }}
  transition={{ duration: 0.25, delay: s.delay / 1000 }}
/>

Screen Shatter Physics

After the three slashes, the screen shatters into 9 fragments using CSS clipPath polygons:

const FRAGMENTS = [
  { clipPath: "polygon(0 0, 33% 0, 33% 33%, 0 33%)",     origin: "0% 0%",    x: "-20%", y: "-30%", rot: -6 },
  { clipPath: "polygon(33% 0, 66% 0, 66% 33%, 33% 33%)",  origin: "50% 16%",   x: "0%",   y: "-35%", rot: 3 },
  { clipPath: "polygon(66% 0, 100% 0, 100% 33%, 66% 33%)", origin: "100% 0%",  x: "25%",  y: "-25%", rot: 8 },
  // ... 6 more fragments covering the full screen
];

// Each fragment animates away from its transform origin
<motion.div
  style={{ background: "#FFF", clipPath: f.clipPath, transformOrigin: f.origin }}
  initial={{ x: 0, y: 0, rotate: 0, opacity: 1 }}
  animate={{ x: f.x, y: f.y, rotate: f.rot, opacity: 0 }}
  transition={{ duration: 0.7, delay: f.delay, ease: [0.22, 1, 0.36, 1] }}
/>

Audio Transitions

Three audio tracks crossfade during the sequence:

  • Loading music — loops during the progress bar phase, fades out over ~600ms when Santoryu triggers
  • Santoryu vocal — plays once during the reveal image
  • Slash sounds — three precisely timed hits, one per slash
const triggerSantoryu = useCallback(() => {
  // Fade out loading music
  const loadAudio = loadingAudioRef.current;
  if (loadAudio) {
    const fade = setInterval(() => {
      if (loadAudio.volume > 0.05) {
        loadAudio.volume = Math.max(0, loadAudio.volume - 0.05);
      } else {
        loadAudio.pause();
        clearInterval(fade);
      }
    }, 30);
  }

  // Play Santoryu vocal
  const santoryu = new Audio(SANTORYU_MUSIC);
  santoryu.volume = 0.8;
  santoryu.play().catch(() => {});

  // Start slashes after vocal ends
  santoryu.onended = () => startSlash();
}, [onComplete]);

Tech Stack

TechnologyPurpose
Next.jsReact framework
TypeScriptType-safe phase management
HTML CanvasPixel art sprite animation (ZoroPixelLoader)
Framer MotionPhase transitions, screen shake, fragment animations
CSS clipPathScreen shatter fragments
Web AudioMusic crossfade, timed slash sounds

Follow the challenge:

Support the challenge:

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion