INSIDE THE EXAMPLE

Alice / 10

Original source files, including the existing teaching notes. Copy a file below or download the complete folder.

css/reset.css



/* CSS Reset */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font-size: 100%;
  font-weight: normal;
}

css/style.css

@import url("reset.css");
@import url("variables.css");

/*
-----------------------------------------
CSS PROPERTIES & VALUES - QUICK REFERENCE
-----------------------------------------

CSS comments use /* and * / (without the space).

UNITS
px    = Pixels. Fixed CSS measurement.
%     = Percentage relative to another value,
        often the parent element.
em    = Relative to the element's font size
        (or parent's font size for font-size).
rem   = Relative to the root HTML font size.
vw    = 1% of the viewport width.
vh    = 1% of the viewport height.
ch    = Approximate width of the "0" character.

TYPOGRAPHY
font-family: Arial, sans-serif;
  Sets the typeface and fallback fonts.

font-size: 16px;
  Sets the size of the text.

font-weight: 400;
  Controls text weight.
  400 = Normal, 700 = Bold.
  Variable fonts may support values 100-900.

line-height: 1.5;
  Controls spacing between lines.
  1.5 means 1.5 times the font size.

letter-spacing: 2px;
  Controls spacing between characters.
  Negative values tighten spacing.

text-align: left;
  Aligns text: left, right, center, justify.

text-transform: uppercase;
  Changes appearance of text:
  uppercase, lowercase, capitalize, none.

COLOUR
color: #222222;
  Sets text colour.

background-color: #ffffff;
  Sets the background colour.

SPACING
margin: 20px;
  Space outside an element.

padding: 20px;
  Space inside an element.

margin-bottom: 20px;
  Adds space below an element.

WIDTH
width: 100%;
  Sets an element's width.

max-width: 800px;
  Limits how wide an element can become.

-----------------------------------------
*/


/*
03 — APPLYING VARIABLES / BASELINE BRIDGE
Numbering update: examples 01 and 02 have since been swapped at your request.
The original-number references below describe the files before that swap:
old 01 is now 02 (styled), and old 02 is now 01 (reset/reference).

The supplied example 02 has a reset and reference notes, but no typography
rules. Both originals are protected. Here we explicitly restore example 01's
basic typography and spacing, substituting variables for reusable values.
This necessary bridge is the only extra change in the colour lesson.
No grid, animation or experimental layout is introduced yet.
Try changing --font-size-h1 or --spacing-large in variables.css.
*/
body {
  background-color: var(--color-background);
  color: var(--color-text);
  font-family: var(--font-primary);
  font-size: var(--font-size-body);
  line-height: 1.6;
  padding: var(--spacing-large);
}
h1 {
  color: var(--color-primary);
  font-size: var(--font-size-h1);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -1px;
  margin-bottom: var(--spacing-medium);
}
h2 {
  color: var(--color-secondary);
  font-size: var(--font-size-h2);
  font-weight: 400;
  line-height: 1.3;
  margin-bottom: var(--spacing-heading);
}
p {
  font-size: var(--font-size-body);
  font-weight: 400;
  line-height: 1.6;
  letter-spacing: 0px;
  max-width: 650px;
  margin-bottom: var(--spacing-medium);
}
/* A long word can wrap on a narrow screen without introducing fluid sizing. */
h1, h2, p { overflow-wrap: break-word; }

/*
04 — EDITORIAL GRID
width: auto fills available space after padding. max-width caps the container
at 1280px. margin: 0 auto centres it using equal automatic side margins.
display: grid arranges direct children into tracks. repeat(3, ...) creates
three equal columns. 1fr means one share of free space; minmax(0, 1fr)
lets a column shrink below its content width. gap adds space BETWEEN tracks.
grid-column: 1 / -1 spans from the first to the last grid line.
align-items: start aligns the tops without stretching short paragraphs.
Try two columns or a larger gap. The single-column mobile lesson follows in 06.
*/
.editorial {
  width: auto;
  max-width: 1280px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--spacing-large);
  align-items: start;
}
.edition, .editorial h1, .editorial h2 { grid-column: 1 / -1; }
.edition {
  color: var(--color-secondary);
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 2px;
  border-top: 2px solid var(--color-primary);
  padding-top: var(--spacing-small);
}
.editorial > p { border-top: 1px solid var(--color-primary); padding-top: var(--spacing-medium); }

/*
05 — EXPERIMENTAL TYPE
The grid stays in place. Later rules deliberately override earlier values;
keeping earlier rules and comments makes the progression visible.
font-size increases the headline to 112px. text-shadow draws an offset copy:
3px right, 3px down, 0 blur. Try a larger offset.
-webkit-text-stroke draws a 1px outline. It is an enhancement: the green fill
remains readable without it, rather than relying on transparent letters.
transform rotates, scales and translates without moving neighbouring boxes.
rotate(-3deg) is anticlockwise; scale(1.02) is 102% of the original size;
translate(8px, -8px) moves right and up. The order of transforms matters.
transform-origin chooses the pivot. Try center instead of left center.
A negative margin creates a small overlap. position: relative allows z-index
(the stacking order) to place the chapter above the headline's shadow.
*/
h1 { font-size: 112px; text-shadow: 3px 3px 0 var(--color-secondary); }
h2 {
  transform: translate(8px, -8px) rotate(-3deg) scale(1.02);
  transform-origin: left center;
  -webkit-text-stroke: 1px var(--color-secondary);
  margin-top: -24px;
  position: relative;
  z-index: 1;
}

/*
06 — RESPONSIVE TYPOGRAPHY
1rem is the root font size (usually 16px); em follows this element's font size.
Using em for letter spacing keeps it proportional when the headline grows.
1vw / 1vh are 1% of viewport width / height. min-height: 100vh gives the page
at least a screen of height, but still allows long text to scroll.
clamp(minimum, preferred, maximum) limits fluid values. The headline uses
9vw + 1rem between 2.5rem and 9rem. Resize the browser to see it respond.
width: 100% fills the container. The inherited max-width still caps its size.
@media applies overrides only when its condition is true. At 700px and below,
three columns become one; reducing the tilt avoids clipping on small screens.
Try changing the breakpoint to 800px, or the preferred headline size to 11vw.
*/
body { padding: clamp(20px, 4vw, 64px); min-height: 100vh; }
.editorial { width: 100%; gap: clamp(20px, 3vw, 40px); }
h1 { font-size: clamp(2.5rem, 9vw + 1rem, 9rem); letter-spacing: -0.045em; }
h2 { font-size: clamp(1.5rem, 4vw, 3rem); }
p { font-size: 1rem; }
@media (max-width: 700px) {
  .editorial { grid-template-columns: minmax(0, 1fr); }
  h2 { transform: translate(0, 0) rotate(-1deg) scale(1); margin-top: 0; }
}

/*
07 — TRANSITIONS AND KEYBOARD FOCUS
A transition interpolates between two property values when a state changes.
:hover is pointer hover; :focus-visible shows keyboard focus. The anchor is a
real link to the opening paragraph, so its visual treatment has a purpose.
inline-block allows transforms on the link. Its parent retains the 05 tilt.
Only listed properties transition; duration is in seconds and the timing
function describes acceleration. Try 1s or linear in variables.css.
A large outline with an offset makes focus visible without moving the layout.
We reduce motion here too, so the first interactive stage is comfortable.
*/
.chapter-link {
  display: inline-block;
  max-width: 100%;
  color: inherit;
  text-decoration: none;
  transition: transform var(--animation-speed) var(--animation-easing),
              color var(--animation-speed) var(--animation-easing);
}
.chapter-link:hover, .chapter-link:focus-visible {
  color: var(--color-primary);
  transform: translate(4px, -4px) rotate(2deg) scale(1.02);
}
:focus-visible { outline: 3px solid var(--color-secondary); outline-offset: 6px; }
@media (prefers-reduced-motion: reduce) {
  .chapter-link { transition: none; }
  .chapter-link:hover, .chapter-link:focus-visible { transform: none; }
}

/*
08 — KEYFRAMES AND STAGGER
Transitions need a change of state; @keyframes defines its own sequence.
0%, 45%, 100% mark progress, not screen positions. Transform and opacity
move/fade the type without repeatedly calculating the document layout.
Duration is the length; delay offsets the start; iteration-count: 1 runs once.
direction: normal goes forwards (try alternate with iteration-count: 2).
fill-mode: both applies the first frame during delay and the last after ending.
The entire opening sequence is under five seconds and does not loop.
Stagger means different elements start at different times. nth-of-type counts
paragraph siblings here. Try changing .15s to .3s and watch the rhythm.
Reduced motion removes the sequence; every word stays visible and readable.
*/
/* The reset normalises every element, including spans. inherit preserves
   the existing bold headline when adding this animation wrapper. */
.kinetic-title {
  font-weight: inherit;
  display: block;
  transform-origin: left center;
  animation-name: type-drift;
  animation-duration: var(--kinetic-duration);
  animation-delay: 0s;
  animation-timing-function: ease-in-out;
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: both;
}
.editorial > p { animation: text-enter 1s ease-out both; }
.editorial > p:nth-of-type(2) { animation-delay: .15s; }
.editorial > p:nth-of-type(3) { animation-delay: .3s; }
@keyframes type-drift {
  0%, 100% { transform: translate(0, 0) rotate(0) scale(1); opacity: 1; }
  45% { transform: translate(0, 8px) rotate(-1deg) scale(.98); opacity: .75; }
}
@keyframes text-enter {
  from { transform: translateY(18px); opacity: .4; }
  to { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .kinetic-title, .editorial > p { animation: none; }
}

/*
09 — CLASSES CONNECT JAVASCRIPT TO CSS
.js-ready stops the automatic opening sequence once controls are available.
.animate restarts the same 08 keyframes. Removing the class resets them.
.paused sets animation-play-state, freezing at the current frame until resumed.
Buttons inherit the typeface; padding enlarges their click/touch area.
Flex-wrap lets controls wrap on small screens. [hidden] must stay hidden even
when a display rule is supplied. Try different button spacing or border width.
*/
.controls {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-small);
  align-items: center;
  border-top: 1px solid var(--color-primary);
  padding-top: var(--spacing-medium);
}
[hidden] { display: none !important; }
button {
  font: inherit;
  font-weight: 700;
  padding: 12px 20px;
  min-height: 48px;
  border: 2px solid var(--color-secondary);
  background: var(--color-secondary);
  color: var(--color-background);
  cursor: pointer;
  transition: background-color var(--animation-speed) var(--animation-easing),
              transform var(--animation-speed) var(--animation-easing);
}
button:hover { background-color: var(--color-primary); transform: translateY(-2px); }
#motion-status { flex-basis: 100%; font-size: .875rem; color: var(--color-text); }
.js-ready .kinetic-title, .js-ready .editorial > p { animation-name: none; }
.js-ready.animate .kinetic-title { animation-name: type-drift; }
.js-ready.animate .editorial > p { animation-name: text-enter; }
.paused .kinetic-title, .paused .editorial > p { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .js-ready.animate .kinetic-title, .js-ready.animate .editorial > p { animation: none; }
  button { transition: none; }
  button:hover { transform: none; }
}

/*
10 — DOWN THE RABBIT-HOLE / A FIVE-PART CHOREOGRAPHY
The inherited editorial grid is the starting composition. The scene above it
contains decorative text copies, never replacements for the reading content.
position: fixed pins the scene to the viewport; inset: 0 fills it. overflow:
hidden contains the travelling words without causing horizontal scrolling.
pointer-events: none lets the reading controls remain usable. Their higher
z-index keeps them in front. Controls become sticky only during the scene,
so they do not cover paragraphs while reading the initial composition.
*/
.controls {
  position: relative;
  bottom: 0;
  z-index: 30;
  padding: var(--spacing-medium);
  background: var(--color-background);
}
.falling .controls, .arrived .controls { position: sticky; }
.rabbit-hole {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 10;
  overflow: hidden;
  pointer-events: none;
  background: var(--color-background);
  color: var(--color-primary);
}
.falling .rabbit-hole, .arrived .rabbit-hole { display: block; }
/* Stage 1 (0–1.2s): the real title tilts and the scene gradually covers it.
   Opacity fades once, rather than flashing. */
.falling .rabbit-hole { animation: scene-enter 1.2s ease-in both; }
.falling .editorial h1 { animation: beginning 1.2s ease-in both; }
@keyframes beginning {
  to { transform: translate(0, 12px) rotate(3deg) scale(.96); }
}
@keyframes scene-enter { from { opacity: 0; } to { opacity: 1; } }
/* Stages 2–4: perspective is the viewer's distance from the 3D scene.
   Smaller values exaggerate depth. preserve-3d retains children's Z positions.
   translate3d(x,y,z) moves in three dimensions: negative Z recedes.
   vmin is 1% of the smaller viewport dimension, keeping the orbit on mobile.
   Each word has a deterministic angle, delay and duration supplied by JS.
   rotate + translate makes an orbit; rotateX tilts it into an ellipse.
   scale and increasingly negative Z make the spiral converge into a tunnel.
   Try 600px perspective or changing 540deg to 720deg for another half-turn.
*/
.tunnel {
  position: absolute;
  inset: 0;
  perspective: 850px;
  perspective-origin: 50% 44%;
  transform-style: preserve-3d;
}
.fragment {
  position: absolute;
  left: 50%;
  top: 44%;
  font-size: clamp(1.2rem, 4.5vw, 4.5rem);
  font-weight: 700;
  letter-spacing: -.04em;
  white-space: nowrap;
  opacity: 0;
  transform-origin: 0 0;
}
.fragment:nth-child(even) { color: var(--color-secondary); }
.fragment:nth-child(5n) { -webkit-text-stroke: 1px var(--color-text); }
.falling .fragment {
  animation: spiral var(--duration) cubic-bezier(.55, .05, .8, .5) var(--delay) both;
}
@keyframes spiral {
  0% {
    opacity: 0;
    transform: rotate(var(--angle)) translate3d(48vmin, 0, 220px) rotateX(20deg) scale(1.2);
  }
  12% { opacity: 1; }
  45% {
    opacity: 1;
    transform: rotate(calc(var(--angle) + 180deg)) translate3d(30vmin, 0, -100px) rotateX(45deg) scale(.85);
    color: var(--color-secondary);
  }
  80% { opacity: .8; color: var(--color-primary); }
  100% {
    opacity: 0;
    transform: rotate(calc(var(--angle) + 540deg)) translate3d(0, 0, -1400px) rotateX(75deg) scale(.04);
  }
}
/* Large typographic echoes form the tunnel walls, at three different depths.
   Their spacing is measured in em, so it grows along with the letters. */
.echo {
  position: absolute;
  inset: 24% -20% auto;
  text-align: center;
  font-weight: 700;
  font-size: clamp(4rem, 20vw, 20rem);
  line-height: .9;
  letter-spacing: -.08em;
  color: var(--color-primary);
  opacity: 0;
}
.echo:nth-of-type(even) { color: var(--color-secondary); }
.falling .echo { animation: tunnel-echo 6s ease-in var(--delay) both; }
@keyframes tunnel-echo {
  0% { opacity: 0; transform: translateZ(300px) rotate(-20deg); }
  15% { opacity: .2; }
  100% { opacity: 0; transform: translateZ(-1800px) rotate(160deg) scale(.1); }
}
/* Stage 5: animationend on this element tells JS the arrival has finished.
   The brief colour change is a single smooth fade, never repeated flashing. */
.arrival {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 6vw 6vw 14rem;
  background: var(--color-primary);
  color: var(--color-background);
  opacity: 0;
}
.arrival strong {
  display: block;
  font-size: clamp(2.5rem, 10vw, 10rem);
  line-height: .85;
  letter-spacing: -.07em;
  font-weight: 700;
}
.arrival-label, .arrival-foot { margin: 1.5rem 0; font-size: clamp(.75rem, 2vw, 1rem); }
.falling .arrival { animation: arrive var(--arrival-duration) ease-out var(--arrival-delay) both; }
.arrived .arrival { opacity: 1; }
@keyframes arrive {
  from { opacity: 0; transform: scale(.8) rotate(-5deg); }
  to { opacity: 1; transform: scale(1) rotate(0); }
}
/* Pause all layers together; resume continues the choreography at that point. */
.paused .rabbit-hole, .paused .fragment, .paused .echo,
.paused .arrival, .paused .editorial h1 { animation-play-state: paused; }
@media (max-width: 700px) {
  .controls { padding: 12px; gap: 8px; }
  .controls button { padding: 10px 12px; font-size: .75rem; }
  .arrival { padding-bottom: 16rem; }
}
/* Short landscape screens retain room above the controls for the arrival. */
@media (max-height: 550px) {
  .arrival { justify-content: flex-start; padding-top: 20px; }
  .arrival strong { font-size: clamp(2rem, 9vh, 4rem); }
  .arrival-label, .arrival-foot { margin: .5rem 0; }
}
@media (prefers-reduced-motion: reduce) {
  .falling .rabbit-hole, .falling .editorial h1, .falling .fragment,
  .falling .echo, .falling .arrival { animation: none; }
  .fragment, .echo { display: none; }
  .falling .arrival { opacity: 1; }
}

/*
WORKSHOP NAVIGATION — shared across the ten examples
These ordinary links open the neighbouring folder without JavaScript.
inline-block keeps the arrows beside the example number. Padding gives each
arrow a comfortable click area; currentColor follows this page's text colour.
The navigation stays in normal flow above the content, so it cannot cover it.
An outline makes keyboard focus visible. Unavailable directions are plain
spans, not links, so they cannot accidentally navigate or receive focus.
*/
.example-navigation {
  font-family: inherit;
  font-size: 14px;
  line-height: 1;
  margin-bottom: 20px;
}
.example-navigation a,
.example-navigation .unavailable {
  display: inline-block;
  padding: 12px;
  font-size: 20px;
  color: inherit;
  text-decoration: none;
}
.example-navigation a:hover { text-decoration: underline; }
.example-navigation a:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
.example-navigation .unavailable { opacity: .35; }
.example-navigation .example-number { font-size: 12px; }

/*
VIEWPORT FIT — examples 09 and 10
Fixed navigation stays at the top left even while the chapter scrolls.
Its z-index places it above the final animation; body padding reserves room.
On desktop, smaller fluid headings and gaps leave room for text and controls.
Height-based units also respond to shorter browser windows. We keep natural
vertical scrolling rather than clipping content or shrinking readable text.
*/
body { padding: 72px clamp(20px, 3vw, 40px) 20px; }
.example-navigation {
  position: fixed;
  top: 8px;
  left: clamp(20px, 3vw, 40px);
  z-index: 40;
  margin: 0;
  background: var(--color-background);
  color: var(--color-text);
}
.editorial { gap: clamp(12px, 2vh, 24px); }
.editorial h1 {
  font-size: clamp(2.5rem, min(6.5vw, 10vh), 6rem);
  margin-bottom: 0;
}
.editorial h2 {
  font-size: clamp(1.5rem, 3vw, 2rem);
  margin-top: 0;
  margin-bottom: 0;
}
.editorial > p { margin-bottom: 0; padding-top: 12px; }
.controls { padding-top: 12px; }
@media (min-width: 1000px) {
  .editorial > p { line-height: 1.45; }
  .controls { gap: 8px; }
}
/* Scroll targets stop below the fixed navigation instead of behind it. */
#opening { scroll-margin-top: 72px; }

/* Keep the arrival below the persistent previous/next navigation. */
.arrival { padding-top: max(72px, 6vw); }
@media (max-height: 550px) {
  .arrival { padding-top: 72px; }
}

/* During the fall, hide the controls without removing their layout space.
   visibility: hidden also removes their buttons from keyboard navigation.
   The existing animationend handler removes .falling on arrival, restoring
   the controls automatically. Escape restores them if the visitor exits early. */
.falling .controls { visibility: hidden; }

css/variables.css


/*
03 — COLOUR AND CUSTOM PROPERTIES
:root selects the document root. Properties beginning -- are names we choose.
var(--color-primary) reads that named value wherever it is needed.
Changing one value here updates all its uses: try swapping pink and green.
Custom properties inherit from parents to children. They are reusable values,
not extra HTML elements. Keep sizes in pixels until the responsive lesson.
*/
:root {
  /* Brand: white reading text on black; pink and green for emphasis. */
  --color-primary: #FF1493;
  --color-secondary: #7FFF00;
  --color-background: #000000;
  --color-text: #FFFFFF;
  /* Typography: these starting sizes come from example 01. */
  --font-primary: Arial, Helvetica, sans-serif;
  --font-size-body: 16px;
  --font-size-h1: 64px;
  --font-size-h2: 32px;
  /* Spacing: shared distances make global adjustments easy. */
  --spacing-small: 10px;
  --spacing-medium: 20px;
  --spacing-large: 40px;
  --spacing-heading: 30px;
}

/* 07: Shared transition duration and easing (the pace of change).
   .35s is 350 milliseconds; ease-in-out starts and ends gently. */
:root {
  --animation-speed: .35s;
  --animation-easing: ease-in-out;
}

/* 08: A separate duration for the short opening keyframe sequence. */
:root { --kinetic-duration: 2.4s; }

/* 10: Timings form one choreographed sequence, not random movement.
   Arrival finishes at 9 seconds; changing these changes the pacing. */
:root {
  --fall-duration: 9s;
  --arrival-delay: 8.2s;
  --arrival-duration: .8s;
}

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Alice's Adventures in Wonderland — Typography Workshop</title>
  <link rel="stylesheet" href="css/style.css">
  <!-- 09: defer waits for the HTML to be parsed before selecting elements. -->
  <script src="js/script.js" defer></script>
</head>
<body>

<!-- Workshop links: each example still loads its own local CSS and JavaScript. -->
<nav class="example-navigation" aria-label="Workshop examples">
  <a href="../html-CSS09/index.html" aria-label="Previous example: 09" title="Previous example">←</a>
  <span class="example-number">10 / 10</span>
  <span class="unavailable" aria-disabled="true" aria-label="No next example">→</span>
</nav>



<!-- 04: One semantic container; source order is still the reading order. -->
<main class="editorial" id="story">
<div class="edition">Lewis Carroll / Chapter 1</div>
<!-- 08: The inner span moves independently of the chapter-link transition. -->
<h1><span class="kinetic-title">Alice's Adventures in Wonderland</span></h1>

<!-- 07: A real link is keyboard accessible and jumps to the reading text. -->
<h2><a class="chapter-link" href="#opening">Down the Rabbit-Hole</a></h2>

<p id="opening" tabindex="-1">
  Alice was beginning to get very tired of sitting by her
  sister on the bank, and of having nothing to do: once or
  twice she had peeped into the book her sister was reading,
  but it had no pictures or conversations in it, "and what
  is the use of a book," thought Alice "without pictures
  or conversations?"
</p>

<p>
  So she was considering in her own mind (as well as she
  could, for the hot day made her feel very sleepy and
  stupid), whether the pleasure of making a daisy-chain
  would be worth the trouble of getting up and picking
  the daisies, when suddenly a White Rabbit with pink
  eyes ran close by her.
</p>

<p>
  There was nothing so very remarkable in that; nor did
  Alice think it so very much out of the way to hear the
  Rabbit say to itself, "Oh dear! Oh dear! I shall be late!"
  (when she thought it over afterwards, it occurred to her
  that she ought to have wondered at this, but at the time
  it all seemed quite natural); but when the Rabbit actually
  took a watch out of its waistcoat-pocket, and looked at
  it, and then hurried on, Alice started to her feet.
</p>


<!-- 09: Hidden until JavaScript connects the controls; reading needs no JS. -->
<div class="controls" hidden>
  <button id="start-button" type="button">FOLLOW THE WHITE RABBIT</button>
  <button id="pause-button" type="button" aria-pressed="false">Pause motion</button>
  <button id="return-button" type="button" hidden>Return to reading</button>
  <span id="motion-status" role="status">Ready to move.</span>
</div>
</main>
<!-- 10: Decorative copies only. The original semantic text stays above.
     aria-hidden prevents duplicate reading; this layer has no focusable items. -->
<div class="rabbit-hole" aria-hidden="true">
  <div class="tunnel" id="tunnel"></div>
  <div class="arrival">
    <span class="arrival-label">Chapter 1 / You have arrived</span>
    <strong>DOWN THE<br>RABBIT-HOLE</strong>
    <span class="arrival-foot">Curiouser &amp; curiouser.</span>
  </div>
</div>
</body>
</html>

js/script.js

// 09 — SELECT, LISTEN, CHANGE A CLASS
// querySelector finds the first element matching a CSS selector.
const page = document.body;
const startButton = document.querySelector("#start-button");
const pauseButton = document.querySelector("#pause-button");
const statusText = document.querySelector("#motion-status");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
page.classList.add("js-ready");
document.querySelector(".controls").hidden = false;

let restartFrame;
function startDemo() {
  // remove resets the old animation. add starts the CSS-defined movement.
  page.classList.remove("animate", "paused");
  pauseButton.setAttribute("aria-pressed", "false");
  pauseButton.textContent = "Pause motion";
  // Two browser frames let the removed class take effect before adding it.
  // Cancel pending work so rapid replay clicks cannot queue up restarts.
  cancelAnimationFrame(restartFrame);
  restartFrame = requestAnimationFrame(() => {
    restartFrame = requestAnimationFrame(() => page.classList.add("animate"));
  });
  statusText.textContent = reducedMotion.matches
    ? "Reduced motion: the text stays still."
    : "The type is moving. Follow the White Rabbit again to restart.";
}
// addEventListener runs a function when a click occurs. Native buttons also
// generate clicks for Enter and Space, so no separate keyboard code is needed.
// The same button starts the sequence and restarts it on subsequent clicks.
startButton.addEventListener("click", startDemo);
pauseButton.addEventListener("click", () => {
  // toggle adds a missing class or removes an existing one; it returns a boolean.
  const paused = page.classList.toggle("paused");
  pauseButton.setAttribute("aria-pressed", String(paused));
  pauseButton.textContent = paused ? "Resume motion" : "Pause motion";
  statusText.textContent = paused ? "Motion paused." : "Motion resumed.";
});

// 10 — EXTEND THE SAME BUTTONS WITH A CHOREOGRAPHED SCENE
// Original paragraphs remain intact. Only decorative copies are split into
// words, inside an aria-hidden layer, so assistive technology reads them once.
const tunnel = document.querySelector("#tunnel");
const arrival = document.querySelector(".arrival");
const returnButton = document.querySelector("#return-button");
const sourceWords = document.querySelector("#opening").textContent.trim().split(/\s+/);
for (let index = 0; index < 28; index += 1) {
  const word = document.createElement("span");
  word.className = "fragment";
  word.textContent = sourceWords[index % sourceWords.length];
  // Repeating values create a deliberate rhythm: no random positions.
  word.style.setProperty("--angle", `${index * 47}deg`);
  word.style.setProperty("--delay", `${1 + index * .075}s`);
  word.style.setProperty("--duration", `${4.2 + (index % 4) * .45}s`);
  tunnel.append(word);
}
for (let index = 0; index < 3; index += 1) {
  const echo = document.createElement("span");
  echo.className = "echo";
  echo.textContent = "DOWN THE RABBIT-HOLE";
  echo.style.setProperty("--delay", `${1 + index * .5}s`);
  tunnel.append(echo);
}

let fallFrame;
function showArrival() {
  page.classList.remove("falling", "paused");
  page.classList.add("arrived");
  pauseButton.hidden = true;
  statusText.textContent = "Down the Rabbit-Hole. You have arrived. Follow the White Rabbit again, or return to reading.";
  // Do not strand keyboard focus on a control that has just become hidden.
  if (document.activeElement === document.body ||
      document.querySelector(".controls").contains(document.activeElement)) {
    startButton.focus({ preventScroll: true });
  }
}
function startFall() {
  cancelAnimationFrame(fallFrame);
  page.classList.remove("falling", "arrived");
  returnButton.hidden = false;
  pauseButton.hidden = reducedMotion.matches;
  // Bring controls into view before the fixed scene covers the page.
  startButton.scrollIntoView({ block: "nearest", behavior: "instant" });
  if (reducedMotion.matches) {
    showArrival();
    return;
  }
  statusText.textContent = "Falling down the rabbit hole. Press Escape to return.";
  fallFrame = requestAnimationFrame(() => {
    fallFrame = requestAnimationFrame(() => page.classList.add("falling"));
  });
}
startButton.addEventListener("click", startFall);
// CSS owns the timing; no duplicate JavaScript timeout has to stay in sync.
arrival.addEventListener("animationend", (event) => {
  if (event.animationName === "arrive") showArrival();
});
function returnToReading() {
  cancelAnimationFrame(fallFrame);
  cancelAnimationFrame(restartFrame);
  page.classList.remove("falling", "arrived", "animate", "paused");
  returnButton.hidden = true;
  pauseButton.hidden = false;
  pauseButton.setAttribute("aria-pressed", "false");
  pauseButton.textContent = "Pause motion";
  statusText.textContent = "Back to the original chapter.";
  document.querySelector("#opening").focus({ preventScroll: true });
  document.querySelector("#opening").scrollIntoView({ block: "start", behavior: "instant" });
}
returnButton.addEventListener("click", returnToReading);
document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") returnToReading();
});
// Respect a preference changed while the experience is already running.
reducedMotion.addEventListener("change", () => {
  if (reducedMotion.matches && (page.classList.contains("falling") || !returnButton.hidden)) {
    cancelAnimationFrame(fallFrame);
    showArrival();
  }
});