Build an Animated Anniversary Timeline

Build a scroll-animated relationship timeline and deploy it live on GitHub Pages.

Introduction

30 Second Summary

Anniversaries call for something personal. Flowers fade and chocolates disappear, but a handmade animated webpage lives at a link she can revisit any time.

In this project, you will build a romantic, scroll-animated website that tells the story of your relationship through milestones, photos, and a click-to-reveal love letter. You will deploy it for free so she can open it from any device.

What You'll Build

Imagine texting her a link. She taps it and sees your names fade in surrounded by floating hearts. As she scrolls, each memory from your six months together slides into view with a photo and caption. At the bottom, she taps a button and your love letter reveals itself one line at a time.

By the end of this project, you'll have:

  • A full-screen animated hero with floating hearts drifting upward and your names fading in, setting the emotional tone the moment she opens the page.
  • A scroll-triggered timeline where each relationship milestone slides into view with a photo and caption as she scrolls down, turning a static list into a story.
  • An interactive love letter where each message appears one at a time on click, building anticipation with every tap.
  • A live link hosted on GitHub Pages that she can open from her phone, laptop, or any device with a single tap.
  • Secret Mission: Add a dreamy particle background and a "Play Our Song" audio toggle button to take the experience from impressive to unforgettable.

Are there any prerequisites?

You need basic familiarity with HTML, CSS, and JavaScript. You also need a free GitHub account. No frameworks or build tools are required for this project.

Before We Start

Before you write any code, take a moment to plan the story you want to tell.

Think about the 5-8 milestones you want to feature in your timeline and the messages you want to reveal in your love letter. Jot down a quick list so you have everything ready when you start building.

Set Up the Project

To build an animated anniversary website, you need a project folder with the right file structure.

In this step, you will create the folder, set up the HTML skeleton that links your CSS and JavaScript files, and initialize Git so you can track your changes and deploy later.

In this step, get ready to:
  • Create the project folder with all required files.
  • Write the HTML boilerplate that connects your CSS and JavaScript.
  • Initialize Git and confirm the page loads in your browser.
Create the project folder and files
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab on your keyboard).

Windows users

Make sure your VS Code terminal is using Git Bash. Click the dropdown arrow next to the + icon in the terminal panel and select Git Bash. This ensures all the commands below work on your machine.

  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir our-story
cd our-story
touch index.html style.css script.js
mkdir images

What do these commands do?

  • cd ~/Desktop moves you to the Desktop so the project folder is easy to find.
  • mkdir our-story creates the project folder.
  • touch creates empty files for your HTML, CSS, and JavaScript.
  • mkdir images creates the subfolder where you will place your photos later.
  • Confirm the files were created by running:
ls

You should see images, index.html, script.js, and style.css listed in the output.

Don't see your files?

  • Make sure you ran cd our-story before touch. If you skipped it, the files were created one level up on the Desktop.
  • If you see a "command not found" error for touch, your terminal is not using Bash. Follow the Windows tip above to switch to Git Bash.

Still stuck? Help me troubleshoot my project folder setup.

Set up the HTML boilerplate

Your project needs an HTML file that links the stylesheet and script together. This single file is what the browser loads first.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the our-story folder, and click Open (macOS) or Select Folder (Windows).
  • In the Explorer sidebar on the left, click index.html to open it in the editor.
  • Replace any existing content with the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Our Story</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <section class="hero"></section>
    <section class="timeline"></section>
    <section class="letter"></section>
    <script src="script.js" defer></script>
</body>
</html>

What does this code do?

  • The <meta charset> and <meta viewport> tags ensure the page renders correctly on all devices and screen sizes.
  • The <link> tag connects your CSS file so styles apply when you add them later.
  • The three <section> elements are empty containers for the hero, timeline, and love letter you will build in the next steps.
  • The defer attribute on the script tag ensures JavaScript runs only after the page finishes loading.
  • Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and move on to the next substep.

ⓧ I'd like to double check the full code

Here is the complete index.html file. Make sure yours matches exactly:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Our Story</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <section class="hero"></section>
    <section class="timeline"></section>
    <section class="letter"></section>
    <script src="script.js" defer></script>
</body>
</html>

File not saving?

  • If the tab at the top shows a dot or circle next to the filename, the file has unsaved changes. Press Cmd+S or Ctrl+S again.
  • Make sure you opened the our-story folder (not a parent folder) so VS Code saves to the correct location.

Need more help? Help me save my index.html file in VS Code.

Initialize Git and verify in the browser

Initializing a Git repository now means you can track every change you make and deploy to GitHub Pages later.

  • In your VS Code terminal, initialize Git by running:
git init

You should see a message that says Initialized empty Git repository.

Now open your page in the browser. What do you expect to see on a page with three empty sections and no CSS?

  • Open index.html in your browser by running:

macOS

open index.html

Windows

start index.html

Your browser opens a blank white page. Look at the browser tab at the top. It reads "Our Story". That confirms your HTML is loading correctly.

Page not loading or showing raw code?

  • If the browser shows the raw HTML text instead of a blank page, make sure you saved index.html with the <!DOCTYPE html> declaration at the very top.
  • If the command did not open a browser, find index.html in the our-story folder on your Desktop and double-click it.
  • If the tab title does not say "Our Story", check that your <title> tag contains exactly Our Story with no extra spaces.

Still not working? Help me open my index.html file in a browser.

Your project skeleton is ready. Next up, you will build the animated hero section that greets her with your names and floating hearts.

Build the Animated Hero Section

Your project skeleton is ready. Now it is time to build the first thing she will see when she opens the link.

The hero section fills the entire screen with an animated title and floating hearts that set the emotional tone. A beautiful first impression makes the difference between her scrolling further or closing the tab.

In this step, get ready to:
  • Build the hero HTML with your names, a subtitle, and heart elements.
  • Style the hero section with a full-screen gradient and centered text.
  • Add CSS keyframe animations for fade-in text and floating hearts.
Build the hero HTML structure

The hero section needs a heading with your names, a subtitle, a scroll-down arrow, and several heart characters that will become floating animations.

  • In VS Code, open index.html.
  • Find the empty <section class="hero"></section> line.
  • Replace it with the following hero content:
<section class="hero">
    <div class="hero-content">
        <h1 class="hero-title">Your Name &amp; Her Name</h1>
        <p class="hero-subtitle">Our 6 Months Together</p>
        <div class="scroll-indicator">&#8595;</div>
    </div>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
    <span class="heart">&#10084;</span>
</section>

What does this code do?

  • .hero-content wraps the text elements so you can animate them as a group.
  • &#8595; renders as a down arrow (the scroll indicator).
  • &#10084; renders as a heart character. Eight separate spans give you eight independently animated hearts.
  • Save index.html.
  • Refresh your browser. You should see unstyled text that reads "Your Name & Her Name", "Our 6 Months Together", a down arrow, and eight heart symbols stacked below.

Don't see any text?

Make sure you replaced the entire <section class="hero"></section> line, not just the content inside it. The opening and closing tags should match the code above.

help me debug why my hero section HTML is not showing any content

Style the hero section

The hero needs to fill the entire screen with a dark gradient and center the text perfectly. You will build this across a few chunks: the page reset and container, then the text styling paired with its fade-in animation.

  • Open style.css and add the CSS reset, body styles, and hero container by pasting the following:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Georgia', serif;
    background: #1a1a2e;
    color: #eee;
    overflow-x: hidden;
}

/* Hero Section */
.hero {
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
    position: relative;
    overflow: hidden;
}

What does this code do?

  • The * reset removes default browser spacing so everything starts from a clean slate.
  • 100vh makes the hero fill the full viewport height, so it is the only thing visible before she scrolls.
  • position: relative and overflow: hidden create a container for the absolutely-positioned hearts to float inside without spilling out.
  • Save style.css and refresh your browser.
  • You should see a dark blue-purple gradient filling the entire screen, with your text and hearts centered in the middle.

Page still has a white background?

Check that your <link rel="stylesheet" href="style.css"> is inside the <head> of index.html. Make sure the filename matches exactly (case-sensitive).

help me debug why my CSS is not loading

  • Add the hero content animation and fade-in keyframe below the .hero rule in style.css:
.hero-content {
    text-align: center;
    z-index: 2;
    animation: fade-in 2s ease forwards;
    opacity: 0;
}

@keyframes fade-in {
    to {
        opacity: 1;
        transform: translateY(0);
    }
    from {
        opacity: 0;
        transform: translateY(20px);
    }
}

What does this code do?

  • .hero-content starts at opacity: 0 (invisible) and the fade-in animation brings it to full opacity over 2 seconds.
  • forwards means the text stays visible after the animation finishes instead of snapping back to invisible.
  • The keyframe also slides the text up 20px with translateY, giving it a gentle rise as it appears.
  • Save style.css and refresh your browser.
  • Watch the page load. Your text should fade in from invisible over 2 seconds with a subtle upward rise.

Text never appears?

If you see only the gradient and no text, make sure you included the @keyframes fade-in block. Without it, the text stays at opacity: 0 forever.

help me figure out why my hero text is not fading in

  • Now add the text styling rules for the title, subtitle, and scroll indicator below the @keyframes fade-in block:
.hero-title {
    font-size: 3rem;
    color: #e94560;
    margin-bottom: 0.5rem;
}

.hero-subtitle {
    font-size: 1.5rem;
    color: #ddd;
    margin-bottom: 2rem;
}

.scroll-indicator {
    font-size: 2rem;
    animation: bounce 2s infinite;
    color: #e94560;
}

What does this code do?

  • .hero-title makes your names large and pink (#e94560) so they pop against the dark background.
  • .scroll-indicator references a bounce animation that you will define in the next chunk.
  • Save style.css and refresh your browser.
  • Your names now fade in with a pink colour, and the subtitle appears in light grey below them. The down arrow shows in pink at the bottom of the text block.
Add the heart float keyframes and bounce animation

The hearts are sitting invisibly at the bottom of the hero. They need positioning rules to scatter them across the screen, plus a keyframe animation to send them drifting upward continuously.

  • Add the bounce keyframe, heart base styles, and individual heart positions below the .scroll-indicator rule in style.css:
@keyframes bounce {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(10px); }
}

/* Floating Hearts */
.heart {
    position: absolute;
    bottom: -50px;
    font-size: 1.5rem;
    color: #e94560;
    opacity: 0.6;
    animation: float-up linear infinite;
    z-index: 1;
}

.heart:nth-child(2) { left: 10%; animation-duration: 8s; animation-delay: 0s; font-size: 1.2rem; }
.heart:nth-child(3) { left: 25%; animation-duration: 10s; animation-delay: 1s; font-size: 1.8rem; }
.heart:nth-child(4) { left: 40%; animation-duration: 7s; animation-delay: 2s; font-size: 1rem; }
.heart:nth-child(5) { left: 55%; animation-duration: 9s; animation-delay: 0.5s; font-size: 2rem; }
.heart:nth-child(6) { left: 70%; animation-duration: 11s; animation-delay: 3s; font-size: 1.4rem; }
.heart:nth-child(7) { left: 80%; animation-duration: 8s; animation-delay: 1.5s; font-size: 1.6rem; }
.heart:nth-child(8) { left: 90%; animation-duration: 10s; animation-delay: 2.5s; font-size: 1.2rem; }
.heart:nth-child(9) { left: 5%; animation-duration: 9s; animation-delay: 4s; font-size: 1.8rem; }

What does this code do?

  • The bounce keyframe makes the scroll-indicator arrow bob gently up and down.
  • Each heart starts at bottom: -50px (hidden below the viewport) and is positioned at a different left percentage to spread them across the screen.
  • Different animation-duration and animation-delay values make each heart float at its own pace, creating a natural staggered look.
  • Now add the final keyframe that actually moves the hearts upward. Paste this below the nth-child rules:
@keyframes float-up {
    0% {
        transform: translateY(0) rotate(0deg);
        opacity: 0.6;
    }
    50% {
        opacity: 0.8;
    }
    100% {
        transform: translateY(-100vh) rotate(45deg);
        opacity: 0;
    }
}

What does this code do?

This keyframe translates each heart from its starting position all the way up past the top of the screen (-100vh). The rotate(45deg) adds a gentle tilt as they rise, and the opacity fades to 0 so they disappear naturally before looping.

Before you refresh, what do you expect to see? Think about what each animation does together.

  • Save style.css and refresh your browser.

Your names fade in with a pink colour and a subtle rise. The subtitle and a bouncing down-arrow appear below. Heart shapes drift upward from the bottom of the screen at different speeds and positions, looping continuously.

Hearts not moving?

Make sure the @keyframes float-up block is placed after the .heart styles. Also check that the animation name in the .heart rule matches exactly: animation: float-up linear infinite.

If you see hearts but they are stuck in place, verify the nth-child numbers match your HTML. The first child of .hero is the .hero-content div, so hearts start at nth-child(2).

help me debug why my floating heart animations are not working

✔️ Awesome, I've got everything!

Great. Double check you have saved both index.html and style.css.

ⓧ I'd like to double check the full code

Scroll up and confirm your files contain each chunk in order. Your index.html should have the hero section with the .hero-content div (containing h1, p, and scroll-indicator div), followed by eight <span class="heart"> elements. The timeline and letter sections remain empty.

Your style.css should contain these blocks in order:

  • CSS reset (*) and body styles.
  • .hero container (100vh, flexbox, gradient, relative, hidden overflow).
  • .hero-content with fade-in animation and @keyframes fade-in.
  • .hero-title, .hero-subtitle, and .scroll-indicator styles.
  • @keyframes bounce (translateY 0 to 10px).
  • .heart base styles and eight nth-child rules (children 2 through 9).
  • @keyframes float-up (translateY to -100vh with rotate and opacity fade).

The hero is alive. She will see your names rise into view surrounded by gently drifting hearts. Next up, you will build the timeline section that tells the story of your relationship as she scrolls.

Build the Timeline Section

Your animated hero grabs her attention the moment the page loads. Now you need to build the emotional core of the experience: a timeline that walks her through your relationship milestones, month by month.

Each milestone gets a photo, a date, and a caption. The timeline alternates items left and right of a vertical line to create visual rhythm as she scrolls through your memories.

In this step, get ready to:
  • Build the timeline HTML structure with 6 milestone entries.
  • Style the layout with a central vertical line and alternating cards.
  • Add placeholder content you will personalize with your own memories.
Build the timeline HTML structure

The timeline lives inside the empty <section class="timeline"> tag that already exists in your HTML. You will fill it with a heading, a wrapper div that draws the vertical line, and 6 timeline items.

  • In index.html, find the empty <section class="timeline"></section> line.
  • Replace it with the following code to add the section heading and the first 3 milestones:
<section class="timeline">
    <h2 class="section-title">Our Memories</h2>
    <div class="timeline-line">
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/01.jpg" alt="First date">
                <span class="timeline-date">January 2026</span>
                <p class="timeline-caption">Our first date. I knew from that moment this was something special.</p>
            </div>
        </div>
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/02.jpg" alt="Second milestone">
                <span class="timeline-date">February 2026</span>
                <p class="timeline-caption">That time we stayed up talking until 3am and forgot the world existed.</p>
            </div>
        </div>
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/03.jpg" alt="Third milestone">
                <span class="timeline-date">March 2026</span>
                <p class="timeline-caption">Our first trip together. Every moment felt like an adventure.</p>
            </div>
        </div>

What does this code do?

Each .timeline-item contains two things: a .timeline-dot (the circle on the line) and a .timeline-content card holding the photo, date, and caption.

The images reference an images/ folder. The photos will show as blank rectangles for now. You will add your real photos before deploying.

  • Directly below the third .timeline-item closing </div>, add the next 3 milestones and close the section:
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/04.jpg" alt="Fourth milestone">
                <span class="timeline-date">April 2026</span>
                <p class="timeline-caption">When you made me laugh so hard I cried. My favourite memory.</p>
            </div>
        </div>
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/05.jpg" alt="Fifth milestone">
                <span class="timeline-date">May 2026</span>
                <p class="timeline-caption">Movie night tradition started here. Our thing, every Friday.</p>
            </div>
        </div>
        <div class="timeline-item">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <img src="images/06.jpg" alt="Sixth milestone">
                <span class="timeline-date">June 2026</span>
                <p class="timeline-caption">Six months. And this is just the beginning.</p>
            </div>
        </div>
    </div>
</section>

What does this code do?

These 3 items complete your 6-month timeline. The closing </div> tags close the .timeline-line wrapper and the closing </section> ends the timeline section.

  • Save index.html.
  • Refresh your browser and scroll past the hero section.

You should see raw unstyled text below the hero: the heading "Our Memories" followed by dates and captions stacked vertically. The images appear as broken-image icons because the photo files do not exist yet.

Don't see any text below the hero?

  • Make sure you saved index.html. Unsaved changes will not appear in the browser.
  • Check that the closing </div> and </section> tags from the second code chunk are present. Missing closing tags can hide content.
  • Confirm the new code is placed between the opening and closing <section class="timeline"> tags, not accidentally inside the hero section.

Why isn't my timeline content showing below the hero?

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html before moving on to styling.

ⓧ I'd like to double check the full code

Your index.html timeline section is too long to show inline. Scroll up and confirm these pieces are present in order:

  • Opening <section class="timeline"> tag.
  • The <h2 class="section-title">Our Memories</h2> heading.
  • Opening <div class="timeline-line"> wrapper.
  • Six .timeline-item divs (January through June 2026).
  • Closing </div> for .timeline-line and closing </section> for the timeline.
Style the timeline with a vertical line and alternating cards

The raw text needs a visual structure. You will create a centered vertical line, then position each milestone card on alternating sides of it.

  • In style.css, add the following at the bottom of the file to style the section heading, container, and vertical line:
/* Section Titles */
.section-title {
    text-align: center;
    font-size: 2.2rem;
    color: #e94560;
    margin-bottom: 3rem;
    padding-top: 4rem;
}

/* Timeline Section */
.timeline {
    padding: 2rem 1rem 4rem;
    max-width: 900px;
    margin: 0 auto;
}

.timeline-line {
    position: relative;
    padding: 2rem 0;
}

.timeline-line::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 0;
    bottom: 0;
    width: 2px;
    background: #e94560;
    transform: translateX(-50%);
}

What does this code do?

  • .section-title styles the "Our Memories" heading with centered red text and top padding to separate it from the hero.
  • .timeline constrains the section to 900px wide and centers it on the page.
  • .timeline-line::before is a CSS pseudo-element that draws a 2px red vertical line down the center of the section.
  • Save style.css and refresh your browser.

You should see "Our Memories" in large red text and a thin vertical red line running below it. The milestone text is still unstyled and positioned to the left.

  • Add the timeline item positioning styles below the code you just wrote:
.timeline-item {
    position: relative;
    width: 45%;
    margin-bottom: 3rem;
    opacity: 0;
    transition: opacity 0.8s ease, transform 0.8s ease;
}

.timeline-item:nth-child(odd) {
    margin-left: 0;
    transform: translateX(-30px);
}

.timeline-item:nth-child(even) {
    margin-left: 55%;
    transform: translateX(30px);
}

.timeline-item.revealed {
    opacity: 1;
    transform: translateX(0);
}

What does this code do?

  • Each .timeline-item is 45% wide and starts at opacity: 0 with a slight horizontal offset. This hides them until JavaScript adds the .revealed class in the next step.
  • Odd items sit on the left (margin-left: 0). Even items sit on the right (margin-left: 55%). This creates the alternating layout.
  • Now add the dot styles that mark each event on the vertical line:
.timeline-dot {
    position: absolute;
    width: 14px;
    height: 14px;
    background: #e94560;
    border-radius: 50%;
    top: 20px;
    border: 3px solid #1a1a2e;
}

.timeline-item:nth-child(odd) .timeline-dot {
    right: -33px;
}

.timeline-item:nth-child(even) .timeline-dot {
    left: -33px;
}

What does this code do?

.timeline-dot places a small red circle on the vertical line to mark each event. The odd/even rules position the dot on opposite sides depending on which side the card sits.

  • Save style.css and refresh your browser.
  • Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • In the Elements panel, expand the .timeline-line node.

You should see "Our Memories" in red and the vertical red line in the browser. The 6 timeline items are invisible because they start at opacity: 0. In DevTools, confirm all 6 .timeline-item elements are present inside .timeline-line.

Why are the items hidden?

The timeline items start hidden so they can dramatically appear one by one as she scrolls. In the next step, you will write JavaScript that watches for scrolling and adds the .revealed class to each item when it enters the viewport.

To preview the layout now, you can temporarily add revealed to any item's class list in DevTools. The card should slide into view. Remove it when you are done testing.

  • Finally, add the content card styles to shape how photos, dates, and captions appear inside each item:
.timeline-content {
    background: #16213e;
    border-radius: 12px;
    padding: 1.5rem;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.timeline-content img {
    width: 100%;
    height: 200px;
    object-fit: cover;
    border-radius: 8px;
    margin-bottom: 1rem;
    background: #0f3460;
}

.timeline-date {
    font-size: 0.85rem;
    color: #e94560;
    font-weight: bold;
    display: block;
    margin-bottom: 0.5rem;
}

.timeline-caption {
    font-size: 1rem;
    line-height: 1.6;
    color: #ccc;
}

What does this code do?

  • .timeline-content creates a dark card with rounded corners and a soft shadow behind each milestone.
  • The img rule sets a fixed 200px height with object-fit: cover so photos crop nicely regardless of their original dimensions. The dark blue background fills in while photos are missing.
  • .timeline-date and .timeline-caption style the text below each photo with readable size and muted colors.
  • Save style.css and refresh your browser.
  • In DevTools, find any .timeline-item element and add revealed to its class list.

You should see a fully styled card slide into view: a dark rounded rectangle with a blue placeholder image area on top, a red date label, and a light caption below. The red dot appears on the vertical line next to the card.

Don't see the red vertical line?

  • Make sure .timeline-line has position: relative. The ::before pseudo-element uses position: absolute and needs a positioned parent to anchor to.
  • Check that content: '' is present in .timeline-line::before. Without it, the pseudo-element does not render.
  • Confirm your CSS file is linked in the <head> of index.html. If the link is missing, no styles will apply.

My timeline vertical line is not showing up. How do I debug CSS pseudo-elements?

✔️ Awesome, I've got everything!

Great. Double check you have saved style.css before moving on.

ⓧ I'd like to double check the full code

Your style.css is now quite long. Scroll up and confirm these CSS blocks are present in order after your existing float-up keyframes:

  • .section-title (centered, red, large font).
  • .timeline (max-width 900px, centered).
  • .timeline-line and .timeline-line::before (the vertical red line).
  • .timeline-item with opacity: 0, plus :nth-child(odd) and :nth-child(even) rules.
  • .timeline-item.revealed (opacity 1, translateX 0).
  • .timeline-dot and its odd/even positioning rules.
  • .timeline-content, .timeline-content img, .timeline-date, and .timeline-caption rules.
Personalize your milestone content

The HTML currently has placeholder text for each milestone. Before you deploy, you will swap these with your own memories.

Make it yours

Update the dates, captions, and image filenames to match your actual relationship milestones. Use short, heartfelt captions that capture a feeling rather than describing the event in detail.

You can also add or remove .timeline-item blocks if you have more or fewer than 6 milestones. Just copy the pattern of dot + content for each new entry.

Your timeline structure and styles are locked in. Next, you will bring it to life with scroll-triggered animations that reveal each memory as she scrolls down the page.

Add Scroll Reveal Animations

Your timeline has the right structure, but scrolling through it lacks storytelling rhythm. The CSS you wrote in the previous step hides each item and offsets it to the side, ready to be revealed. Without JavaScript to detect when someone scrolls to each milestone, they stay invisible.

In this step, you'll use the IntersectionObserver API to watch each timeline item. The moment one enters the viewport, it gracefully fades and slides into view, creating the feeling of a story unfolding as she scrolls.

In this step, get ready to:
  • Write the IntersectionObserver to reveal timeline items on scroll.
  • Test the animation by scrolling through the timeline.
Write the IntersectionObserver

IntersectionObserver is a browser API that fires a callback whenever an element enters or leaves the viewport. Instead of listening to scroll events (which fire hundreds of times per second), it efficiently watches elements and only reacts when they become visible.

  • Open script.js in VS Code.
  • Add the scroll reveal logic by pasting in the following code:
// Scroll Reveal for Timeline Items
const timelineItems = document.querySelectorAll('.timeline-item');

const revealObserver = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            entry.target.classList.add('revealed');
            revealObserver.unobserve(entry.target);
        }
    });
}, {
    threshold: 0.2
});

timelineItems.forEach((item) => {
    revealObserver.observe(item);
});

What does this code do?

  • querySelectorAll('.timeline-item') grabs all six timeline items into a list.
  • new IntersectionObserver(...) creates a watcher that fires a callback whenever an observed element enters the viewport.
  • threshold: 0.2 means the callback fires once 20% of the item is visible. This prevents items from revealing too early.
  • classList.add('revealed') triggers the CSS transition (opacity 0 to 1, translateX back to 0) that makes the item fade and slide in.
  • revealObserver.unobserve(entry.target) stops watching the item after it has appeared. This means items stay visible once revealed, even if you scroll away and back.
  • Save script.js.
Test the scroll animation

Before you refresh the page, think about what you expect to see. When the page loads, will the timeline items be visible or hidden? What should happen when you scroll down to them?

  • Refresh your browser.
  • Scroll past the hero section into the timeline.

Each timeline item fades in and slides from its respective side as it enters the viewport. Items below the fold remain hidden until you scroll to them. Once an item has been revealed, it stays visible even if you scroll back up.

Why IntersectionObserver instead of scroll events?

A scroll event listener fires hundreds of times per second as the user scrolls, which can slow down the page. IntersectionObserver is built into the browser and only fires when elements actually cross the visibility threshold. It is the modern, performant way to trigger animations on scroll.

Timeline items not appearing on scroll?

  • Make sure you saved script.js and did a hard refresh (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows). The browser may have cached the old empty script.
  • Check that your style.css has .timeline-item.revealed with opacity: 1 and transform: translateX(0). Without this class, adding revealed in JavaScript has no visual effect.
  • Open your browser's DevTools console and check for any red error messages. A typo in the selector or a missing bracket can silently break the script.

Why aren't my timeline items revealing on scroll?

✔️ Awesome, I've got everything!

Great. Double check you've saved script.js and that your scroll animation is working smoothly.

ⓧ I'd like to double check the full code

Here is the complete script.js file. Make sure yours matches exactly:

// Scroll Reveal for Timeline Items
const timelineItems = document.querySelectorAll('.timeline-item');

const revealObserver = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            entry.target.classList.add('revealed');
            revealObserver.unobserve(entry.target);
        }
    });
}, {
    threshold: 0.2
});

timelineItems.forEach((item) => {
    revealObserver.observe(item);
});

Your timeline now tells a story as she scrolls through it. Each memory appears at just the right moment. Next up, you'll build the emotional climax: an interactive love letter where each message reveals one at a time on click.

Build the Interactive Love Letter

Your timeline takes her on a journey through your shared memories. Now she needs a destination that hits on a deeper, more personal level.

The love letter is the emotional climax of the page. Instead of dumping all your heartfelt words on screen at once, each message reveals one at a time when she clicks a button. That anticipation makes her feel each line individually.

In this step, get ready to:
  • Build the letter HTML with hidden messages and a reveal button.
  • Style the letter section with elegant animations and a pulsing button.
  • Write the click-to-reveal JavaScript logic.
Build the letter HTML structure

The letter section needs a header, individual message paragraphs that start hidden, a button to reveal them, and a closing line that appears at the very end.

  • In index.html, find the empty <section class="letter"></section> tag.
  • Replace it with the following letter section containing your messages:
    <section class="letter">
        <h2 class="section-title">A Letter For You</h2>
        <div class="letter-messages">
            <p class="letter-msg">From the moment I met you, everything changed.</p>
            <p class="letter-msg">You make the ordinary feel extraordinary.</p>
            <p class="letter-msg">Every laugh we share is my favourite sound.</p>
            <p class="letter-msg">You are my best friend, my favourite person, my home.</p>
            <p class="letter-msg">Thank you for six incredible months.</p>
            <p class="letter-msg">Here's to forever more.</p>
        </div>
        <button class="reveal-btn">Tap to reveal</button>
        <p class="letter-closing">I love you. Always.</p>
    </section>

What does this code do?

  • Each <p class="letter-msg"> is a single love letter line. CSS will hide them all by default.
  • The .reveal-btn button triggers JavaScript to show the next message on each click.
  • The .letter-closing paragraph appears only after all messages are revealed, as the final emotional beat.
  • Save index.html and refresh your browser.
  • Scroll past the timeline. You should see the heading "A Letter For You", all six messages as plain text, an unstyled button, and the closing line all visible at once.

Don't see the letter section?

Make sure you replaced the entire empty <section class="letter"></section> tag, not just pasted inside it (which would create nested section tags). Check for mismatched closing tags.

Help me debug my letter section HTML.

Style the letter layout and messages

Right now the messages are all visible as plain text. You need CSS to hide them and set up the transition that will reveal them smoothly when JavaScript adds the .visible class.

  • In style.css, add the following styles at the bottom of your file (after the responsive media query):
/* Letter Section */
.letter {
    padding: 2rem 1rem 6rem;
    max-width: 600px;
    margin: 0 auto;
    text-align: center;
}

.letter-messages {
    margin-bottom: 2rem;
}

.letter-msg {
    font-size: 1.3rem;
    line-height: 1.8;
    margin-bottom: 1.5rem;
    opacity: 0;
    max-height: 0;
    overflow: hidden;
    transition: opacity 0.8s ease, max-height 0.8s ease;
}

.letter-msg.visible {
    opacity: 1;
    max-height: 100px;
}

What does this code do?

  • .letter-msg starts each message at opacity: 0 and max-height: 0 so they take up no space and are invisible.
  • When JavaScript adds the .visible class, the message smoothly expands and fades in over 0.8 seconds.
  • The overflow: hidden prevents text from leaking out while max-height is 0.
  • Save style.css and refresh your browser.
  • Scroll to the letter section. The six message lines have disappeared. You should see only the heading "A Letter For You", the unstyled button text, and the closing line.

Messages still showing?

Check that your class name in CSS (.letter-msg) matches the HTML exactly (class="letter-msg"). Also confirm you have both opacity: 0 and max-height: 0 set on the selector.

Help me figure out why my letter messages are still visible.

  • Now style the reveal button by adding this CSS below your previous letter styles:
.reveal-btn {
    background: #e94560;
    color: white;
    border: none;
    padding: 1rem 2.5rem;
    font-size: 1.1rem;
    border-radius: 50px;
    cursor: pointer;
    animation: pulse 2s infinite;
    transition: transform 0.2s;
}

.reveal-btn:hover {
    transform: scale(1.05);
}

.reveal-btn.hidden {
    display: none;
}

@keyframes pulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(233, 69, 96, 0.4); }
    50% { box-shadow: 0 0 0 15px rgba(233, 69, 96, 0); }
}

What does this code do?

  • The button gets a pill shape with border-radius: 50px and scales slightly on hover for tactile feedback.
  • The @keyframes pulse animation creates a soft glowing ring that expands and fades continuously, inviting her to tap.
  • .reveal-btn.hidden hides the button after the last message is revealed.
  • Save style.css and refresh your browser.
  • The button is now a rounded red pill shape with a soft pulsing glow animating around it.
  • Finally, add the closing text styles below your button styles:
.letter-closing {
    font-size: 1.8rem;
    color: #e94560;
    margin-top: 2rem;
    opacity: 0;
    transition: opacity 1.5s ease;
}

.letter-closing.visible {
    opacity: 1;
}

What does this code do?

The closing line starts hidden at opacity: 0. When JavaScript adds .visible, it fades in over 1.5 seconds for a slow, emotional reveal.

  • Save style.css and refresh your browser.
  • The "I love you. Always." text that was previously visible has now disappeared. Only the heading and the pulsing button remain visible in the letter section.
Write the reveal logic in JavaScript

The CSS handles how things look when they appear. Now you need JavaScript to control when they appear. Each button click should reveal the next hidden message, and after the last one, the button disappears and the closing line fades in.

  • In script.js, add the following code below your existing IntersectionObserver logic:
// Love Letter Reveal
const messages = document.querySelectorAll('.letter-msg');
const revealBtn = document.querySelector('.reveal-btn');
const closing = document.querySelector('.letter-closing');
let currentMsg = 0;

revealBtn.addEventListener('click', () => {
    if (currentMsg < messages.length) {
        messages[currentMsg].classList.add('visible');
        currentMsg++;

        if (currentMsg >= messages.length) {
            revealBtn.classList.add('hidden');
            closing.classList.add('visible');
        }
    }
});

What does this code do?

  • currentMsg tracks which message to reveal next, starting at index 0.
  • Each click adds the .visible class to the current message (triggering the CSS transition) and increments the counter.
  • Once all messages are shown, the button gets .hidden (disappears) and the closing paragraph gets .visible (fades in slowly).
  • Save script.js.

Before you refresh, think about what should happen when you click the button. How many clicks until the button disappears?

  • Refresh your browser and scroll to the letter section.
  • Click the pulsing "Tap to reveal" button.

Each click reveals the next message with a smooth fade-in. After six clicks (one per message), the button disappears and "I love you. Always." fades in slowly as the final line.

Button not doing anything?

  • Open your browser's developer console (press F12 or Cmd+Option+J on macOS) and look for red error messages. A typo in a class name like .reveal-btn will cause the script to fail silently.
  • Make sure your <script> tag is at the bottom of the body with the defer attribute. Without it, the script runs before the button exists in the page.

Help me debug my love letter reveal button.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Scroll up and confirm each code chunk is present in the correct file, in this order:

  • index.html: The <section class="letter"> contains an h2, a .letter-messages div with 6 paragraphs, a button, and the closing paragraph.
  • style.css: Letter layout and message styles (.letter, .letter-messages, .letter-msg, .letter-msg.visible).
  • style.css: Button and pulse styles (.reveal-btn, .reveal-btn:hover, .reveal-btn.hidden, @keyframes pulse).
  • style.css: Closing text styles (.letter-closing, .letter-closing.visible).
  • script.js: The Love Letter Reveal block with revealBtn.addEventListener('click', ...) below your existing IntersectionObserver code.

Your love letter now delivers each message with anticipation and care. The entire experience is ready to share. Next up, you will deploy it to GitHub Pages so she can open it from any device with a single link.

Deploy to GitHub Pages

Your animated timeline works beautifully on your computer. But the whole point is for her to open a link on her phone and be swept away.

In this step, you will push your project to GitHub and enable GitHub Pages so she can experience it from any device with a single URL you text her.

In this step, get ready to:
  • Add your real photos and push all code to GitHub.
  • Enable GitHub Pages to host your site for free.
  • Test the live link on your phone and desktop.
Create a GitHub repository and push your code

Before you push, you need real photos in the timeline. Your HTML already references files named 01.jpg through 06.jpg inside the images/ folder.

  • Choose 6 photos that match your timeline milestones.
  • Copy them into the images/ folder inside your our-story project folder.
  • Rename them 01.jpg, 02.jpg, 03.jpg, 04.jpg, 05.jpg, and 06.jpg to match the filenames in your HTML.
  • Confirm your photos are in place by running this command in your terminal:
ls images/

You should see 01.jpg through 06.jpg listed in the output.

Personalise your content too

Now is a great time to update the placeholder text in index.html. Change "Your Name & Her Name" to your real names, update the dates and captions to match your actual milestones, and write your own love letter messages.

Next, create an empty repository on GitHub to host your files.

  • Open your browser and go to github.com.
  • In the upper-right corner, click the + menu, then click New repository.
  • In the Repository name field, type our-story.
  • Leave the visibility set to Public.
  • Make sure Add README is toggled Off. You already have files to push, so you do not want GitHub to create any initial files.
  • Click Create repository.

You will see a Quick Setup page with instructions. Leave this page open.

Why leave it empty?

If you initialise the repository with a README, it creates a commit on GitHub that conflicts with your local commits. Starting empty avoids merge conflicts when you push.

Now commit your files and push them to GitHub.

  • Return to your terminal (make sure you are inside the our-story folder).
  • Stage and commit all your files by running these commands:
git add .
git commit -m "Initial commit"
  • Connect your local repository to GitHub and push by running these commands:
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/our-story.git
git push -u origin main

What do these commands do?

  • git branch -M main renames your current branch to main so it matches what GitHub expects.
  • git remote add origin tells Git where to push your code on the internet.
  • git push -u origin main uploads all your committed files to GitHub.
  • Refresh the GitHub repository page in your browser.

You should see all your files listed: index.html, style.css, script.js, and the images/ folder with your photos.

Push rejected or authentication failed?

  • If Git asks for a password, it needs a personal access token instead. Go to GitHub Settings > Developer settings > Personal access tokens to generate one.
  • If you see "remote origin already exists", run git remote set-url origin https://github.com/YOUR-USERNAME/our-story.git to update it.
  • If you see "failed to push some refs", make sure you did NOT initialise the GitHub repo with a README.

Still stuck? Help me push my code to GitHub.

Enable GitHub Pages

Your code is on GitHub, but it is not yet a live website. GitHub Pages turns any repository into a hosted site for free.

  • On your repository page, click Settings in the top navigation bar.
  • In the left sidebar under Code and automation, click Pages.
  • Under Build and deployment, set Source to Deploy from a branch.
  • Select the main branch and / (root) folder from the dropdowns.
  • Click Save.

How long does deployment take?

GitHub Pages can take up to 10 minutes to publish your site. In practice, it usually takes 1-2 minutes. You will see a green checkmark on the Pages settings page when deployment is complete.

  • Wait 2 minutes, then refresh the Pages settings page.
  • Look for the message showing your live URL at the top of the page.
Test on multiple devices

Before you run the final test, what do you think will happen when you open this URL? Will the animations work the same as they did locally?

  • Open your live site in the browser by visiting:
https://[[GITHUB_USERNAME="your GitHub username"]].github.io/our-story/

You should see the full experience: the animated hero with floating hearts, the scroll-triggered timeline with your photos, and the click-to-reveal love letter.

  • Open the same URL on your phone to confirm it looks good on mobile.
  • Send the link to yourself via text message or email and open it from there, to verify it works for someone who is not logged into GitHub.

Seeing a 404 page?

  • Double check that the repository name is exactly our-story (the URL is case-sensitive).
  • Confirm that GitHub Pages is set to the main branch and / (root) folder.
  • Make sure your repository has a file called index.html at the top level (not inside a subfolder).
  • Wait a few more minutes. Deployment can take up to 10 minutes.

Still stuck? Help me troubleshoot my GitHub Pages deployment.

Photos not loading on the live site?

Check that the filenames in your images/ folder exactly match the src attributes in your HTML, including capitalisation and file extension. Photo.JPG is not the same as photo.jpg.

Secret mission

Add Particle Background and Play Our Song Audio

Your animated timeline is already impressive. But what if the hero section had a dreamy, glowing particle system drifting behind the title? And what if she could press a button to play your song while experiencing the page? Take this from impressive to unforgettable.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free GitHub services, so there are no ongoing costs.

Resources you used:

  • Local our-story project folder on your Desktop.
  • GitHub repository (our-story, free).
  • GitHub Pages site (free, hosted from the repository).

Keep everything running

No action needed. Choose this if you want her to keep visiting the link any time she wants.

  • Your GitHub Pages site stays live at no cost.
  • She can revisit the link whenever she likes.
  • Push new commits to the main branch any time to add more milestones, update photos, or tweak the design. Changes deploy automatically.

Pause - I'll come back to this later

Disable the live site while keeping all your code intact in the repository. This hides the page from anyone with the link until you re-enable it.

  • Go to your our-story repository on GitHub.
  • Click Settings, then Pages in the sidebar under Code and automation.
  • Under Build and deployment, change the Source to None and click Save.
  • Your code stays in the repository. To re-enable, set the Source back to Deploy from a branch and select main.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes the live site and the code from GitHub.

  • Delete the GitHub repository (this also removes the Pages site):
  • Go to your our-story repository on GitHub.
  • Click Settings.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Delete the local project folder by running this in your terminal:

rm -rf ~/Desktop/our-story

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\our-story

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully animated, personal website from scratch and deployed it live for someone special to open from any device.

You've learned how to:

  • Build a full-screen animated hero with CSS keyframe animations and floating heart elements that drift continuously.
  • Create a scroll-triggered timeline using IntersectionObserver to reveal relationship milestones with photos as the viewer scrolls.
  • Build an interactive click-to-reveal love letter with smooth CSS transitions and deploy the whole experience to GitHub Pages for free.
  • Secret Mission: Added a canvas particle system for a dreamy background atmosphere and an audio playback toggle so she can listen to your song.

Ready to quiz yourself?