Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/choose-your-destiny/llms.txt

Use this file to discover all available pages before exploring further.

The animation system in Choose Your Destiny is layered across three distinct technologies: raw CSS @keyframes for the purely cosmetic CRT effects, CSS custom properties for ambient background motion, and Framer Motion for all state-driven UI transitions. The CSS animations are designed to feel broken by default — jagged clip masks, stepped opacity blinks, and slow phosphor flicker — while the Framer Motion layers supply the smooth, physics-aware responses that make interactions feel polished beneath the glitch veneer. Understanding which layer does what is the key to extending the system without fighting it.

GlitchText

GlitchText (exported from VisualEffects.js) renders an arcade-font heading that appears to corrupt and reassemble itself continuously. The effect is achieved entirely with CSS — no JavaScript RAF loops or canvas drawing.

How it works

The component renders the text once as visible content, then duplicates it twice using :before and :after pseudo-elements that read the same string from a data-text attribute. Both copies sit position: absolute over the original, each clipped to a narrow horizontal slice via the clip: rect(...) property. Their clip rectangles animate through 21 distinct positions at different rates and with alternate-reverse direction, ensuring the before/after layers are rarely in sync with each other or with the original — producing the characteristic fragmented corruption look. The :before layer uses a magenta text-shadow offset of -2px 0, the :after layer uses cyan at the same offset, mirroring the chromatic aberration of a misaligned CRT electron gun.

CSS keyframes

.glitch-text {
  position: relative;
  display: inline-block;
}

/* Shared setup for both pseudo-elements */
.glitch-text::before,
.glitch-text::after {
  content: attr(data-text);
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  opacity: 0.8;
}

/* Magenta layer — 3s, alternate-reverse */
.glitch-text::before {
  left: 2px;
  text-shadow: -2px 0 var(--neon-magenta);
  clip: rect(24px, 550px, 90px, 0);
  animation: glitch-anim-2 3s infinite linear alternate-reverse;
}

/* Cyan layer — 2.5s, alternate-reverse */
.glitch-text::after {
  left: -2px;
  text-shadow: -2px 0 var(--neon-cyan);
  clip: rect(85px, 550px, 140px, 0);
  animation: glitch-anim 2.5s infinite linear alternate-reverse;
}

@keyframes glitch-anim {
  0%   { clip: rect(10px,  9999px, 44px,  0); }
  5%   { clip: rect(70px,  9999px, 59px,  0); }
  10%  { clip: rect(20px,  9999px, 80px,  0); }
  15%  { clip: rect(89px,  9999px, 12px,  0); }
  20%  { clip: rect(34px,  9999px, 90px,  0); }
  /* ... 21 steps total, ending at: */
  100% { clip: rect(34px,  9999px, 12px,  0); }
}

@keyframes glitch-anim-2 {
  0%   { clip: rect(65px,  9999px, 100px, 0); }
  5%   { clip: rect(52px,  9999px, 74px,  0); }
  /* ... 21 steps, different rhythm from glitch-anim */
  100% { clip: rect(38px,  9999px, 49px,  0); }
}

JSX usage

import { GlitchText } from "./components/VisualEffects";

// Renders as an <h1> by default; use the `as` prop to change the tag
<GlitchText text="CHOOSE YOUR DESTINY" />

// Render as an <h2> with extra wrapper classes
<GlitchText text="PROJECTS" as="h2" className="mb-8" />
The text prop populates both the visible text content and the data-text attribute that pseudo-elements read via attr(data-text). If you change the displayed text after mount (e.g. from an animation), make sure data-text stays in sync — otherwise the glitch layers will show stale content.

ScanlineOverlay

ScanlineOverlay (exported from VisualEffects.js) renders a fixed full-viewport layer at z-50 that overlays every element on the page with a horizontal scanline pattern. It simulates the horizontal phosphor line structure of a CRT display.

How it works

The overlay is a single div with position: fixed; inset: 0; pointer-events: none so it never captures mouse or touch events. Its background is a CSS linear-gradient that alternates between transparent and a semi-transparent black at every 2px interval, producing dark horizontal bands across the full screen.
.scanlines {
  background: linear-gradient(
    to bottom,
    transparent 0%,
    transparent 50%,
    rgba(0, 0, 0, 0.2) 50%,
    rgba(0, 0, 0, 0.2) 100%
  );
  background-size: 100% 4px; /* one scanline every 4px */
}
The component applies .scanlines at opacity-20, keeping the effect subtle enough to not obscure underlying content while still communicating the CRT texture at a glance.
// ScanlineOverlay — source (VisualEffects.js)
const ScanlineOverlay = () => (
  <div className="fixed inset-0 pointer-events-none z-50 scanlines opacity-20" />
);

JSX usage

import { ScanlineOverlay } from "./components/VisualEffects";

// Mount once at the root layout level — it covers the entire viewport
function App() {
  return (
    <>
      <ScanlineOverlay />
      {/* rest of app */}
    </>
  );
}

CRT Flicker

Alongside the scanline overlay sits a second fixed layer (exported as CRTFlicker from VisualEffects.js) that drives a slow, breathing opacity oscillation. While scanlines provide the static grid texture, the flicker overlay simulates the low-frequency brightness instability of an aging cathode-ray tube.

CSS animation

.crt-flicker {
  animation: flicker 4s ease-in-out infinite;
  pointer-events: none;
  will-change: opacity;          /* promotes to GPU composite layer */
  transform: translateZ(0);      /* forces layer promotion in older engines */
}

@keyframes flicker {
  0%   { opacity: 0.9;  }
  50%  { opacity: 0.55; }
  100% { opacity: 0.9;  }
}
The oscillation is slow (4s period) and smooth (ease-in-out) so it reads as an ambient quality rather than a distracting strobe. The bg-white/[0.015] background color on the overlay element itself provides a barely-there brightening at full opacity, which dims to near-nothing at the 50% trough.
import { CRTFlicker } from "./components/VisualEffects";

<CRTFlicker />  {/* z-[49], sits just below ScanlineOverlay's z-50 */}

PerspectiveGrid

PerspectiveGrid (exported from VisualEffects.js) renders the animated receding grid that appears at the bottom of several sections. It recreates the classic synthwave horizon floor using only CSS transforms and an infinitely scrolling background-position.

How it works

PerspectiveGrid renders as a fixed full-width overlay anchored to the bottom of the viewport (fixed bottom-0 left-0 w-full h-[40vh]). Inside it, a 200% × 200% div is offset left by -50% to center the grid beyond the visible viewport edges. The inner element has a CSS perspective transform applied that rotates it flat toward the viewer, and an infinite background-position animation that moves the grid lines downward, creating the illusion of flying forward through a neon grid tunnel.
.perspective-grid {
  background-image:
    linear-gradient(to right,  rgba(34, 211, 238, 0.2) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(34, 211, 238, 0.2) 1px, transparent 1px);
  background-size: 50px 50px;

  transform: perspective(500px) rotateX(60deg) translateZ(0);
  transform-origin: top center;

  animation: grid-move 20s linear infinite;
  will-change: background-position;
  backface-visibility: hidden;
}

@keyframes grid-move {
  from { background-position: 0 0;    }
  to   { background-position: 0 50px; } /* advances exactly one cell per cycle */
}
The 50px end value in grid-move matches the background-size cell height exactly, so the loop is perfectly seamless.

Color variants

PerspectiveGrid accepts a color prop that selects one of three preset grid line RGBA values:
const gridColors = {
  cyan:    "rgba(34,  211, 238, 0.2)",
  magenta: "rgba(236,  72, 153, 0.2)",
  purple:  "rgba(168,  85, 247, 0.2)",
};

JSX usage

import { PerspectiveGrid } from "./components/VisualEffects";

// Mount once at the root layout level — it uses fixed positioning and covers the
// bottom of the viewport regardless of scroll position, sitting behind all content at z-0
function App() {
  return (
    <>
      <PerspectiveGrid color="cyan" />
      {/* rest of app content sits above the grid via z-index */}
    </>
  );
}

// Magenta grid for the Contact section accent
<PerspectiveGrid color="magenta" />

BlinkingCursor

BlinkingCursor (from UIComponents.js) uses a step-end keyframe rather than the default ease, making the opacity change instantaneous at the 50% frame boundary — a binary on/off flick with no fade.
.blinking-cursor {
  animation: blink 1s step-end infinite;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0; }
}
The step-end timing function is what distinguishes this from a pulse animation. With ease or linear, opacity would ramp smoothly; step-end holds at opacity: 1 for the full first half of each second, then snaps to opacity: 0 at the 50% frame, holding there until the cycle resets at 100%. This matches the exact behavior of a hardware terminal cursor.

Framer Motion Animations

Beyond CSS, Framer Motion handles all state-driven UI motion: skill bar width reveals, stage card entry, and HP bar drain in the hero section.

Skill Bar Width Animation

Skill bars animate from width: 0% to their target percentage when they enter the viewport. Framer Motion’s whileInView trigger fires the animation lazily, so bars that haven’t scrolled into view remain at zero until needed.
import { motion } from "framer-motion";

<motion.div
  className="h-2 bg-neon-lime"
  initial={{ width: 0 }}
  whileInView={{ width: `${skillPercent}%` }}
  transition={{ duration: 1, ease: "easeOut" }}
  viewport={{ once: true }}
/>

Stage Reveal (Opacity + X Slide)

Section content panels enter from the right with a combined opacity fade and horizontal x slide. The x displacement is typically 40px60px, giving the impression of content being rendered in from off-screen.
import { motion } from "framer-motion";

<motion.div
  initial={{ opacity: 0, x: 40 }}
  animate={{ opacity: 1, x: 0 }}
  transition={{ duration: 0.4, ease: "easeOut" }}
>
  {/* stage content */}
</motion.div>

HP Bar Drain

The hero HP bar drains from full to the player’s effective HP level on mount, producing a game-boot feel. The animation uses a negative width transition starting at 100% and settling at the remaining-HP percentage.
import { motion } from "framer-motion";

<motion.div
  className="h-3 bg-neon-lime"
  initial={{ width: "100%" }}
  animate={{ width: `${hpPercent}%` }}
  transition={{ duration: 1.2, ease: "easeInOut", delay: 0.5 }}
/>

Performance Notes

All four CSS animations that run continuously are optimised for GPU compositing:
Both .crt-flicker and .perspective-grid declare will-change on the property they animate. This tells the browser’s compositor to promote the element to its own layer before the animation begins, avoiding expensive paint operations on every frame.
.crt-flicker     { will-change: opacity; }
.perspective-grid { will-change: background-position; }
The .glitch-text and .blinking-cursor animations intentionally omit will-change. Glitch clips change rapidly enough that the overhead of a dedicated layer would outweigh the benefit, and the blinking cursor is a tiny 3×5 px element where layer promotion would waste memory. Only promote elements to their own compositor layer when the animation runs on a large surface or at a high frame rate.

Build docs developers (and LLMs) love