Add CRT and VHS Effects to a Web Page

Layer particle, VHS and CRT effects onto a page without breaking readability.

Introduction

30 Second Summary

Most pages built with an AI assistant look the same, because nobody ever touches the texture. The layout is correct and the page still feels like nothing.

In this project, you will layer three screen effects onto a plain page: a particle background, a VHS treatment and CRT scanlines. Then you will find the point where the effect starts costing you more in readability than it gives you back in atmosphere.

What You'll Build

A live URL you can open on your phone and hand to a friend, where particles drift behind your heading, scanlines roll over the whole page, and everything goes still for anyone who has asked their system for less motion.

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

  • A deployed page with a particle background moving under your text, and a VHS treated image sitting beside its untouched original so you can point at exactly what changed.
  • A CRT scanline layer you can dial up and down, plus two screenshots bracketing the moment your body text stopped being readable.
  • A reduced motion fallback you can prove works by flipping the setting in your own operating system and reloading the page.
  • Secret Mission: Add a toggle that switches all three effects off and back on, and remembers the visitor's choice so the page loads their way next time.

Are there any prerequisites?

You need Node installed, a plain HTML or React page you are comfortable editing, and a browser with working WebGL.

The effect components and the static hosting are both free, so this project costs you nothing to finish.

Set Up Your Project

Screen effects are drop-in components. Before you can drop any of them onto a page, you need somewhere for that page to live and a way to see it change as you edit.

This step builds that workspace. You'll get a code editor open, create the folder your page lives in, and add a live preview so the browser updates the moment you save.

In this step, get ready to:
  • Get your code editor open and ready to edit.
  • Create the crt-vhs-page folder your page will live in.
  • Install a live preview so the browser reloads when you save.
Open your code editor

Every effect in this project is CSS and JavaScript you paste into files. A code editor is where that happens, and VS Code is the one this project uses.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.

✔️ VS Code opens

Your editor is ready. Leave the window open, because you'll create your project folder inside it next.

ⓧ Nothing opens, I don't have it installed

  • Open the Visual Studio Code download page in your browser.
  • Click the download button for your operating system.
  • Run the installer once the download finishes, and accept the default options.
  • Press Cmd+Space (macOS) or the Windows key (Windows), then type VS Code and press Enter.

The editor window opens on a Welcome tab. That's your signal to carry on.

Create your project folder

Your page, your styles and every effect component you copy in later all live in one folder. Putting it on your Desktop keeps it easy to find and one command away in the terminal.

  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal to open a terminal panel at the bottom of the window.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir crt-vhs-page
cd crt-vhs-page
  • Confirm where you are by running this command:
pwd

The output ends with Desktop/crt-vhs-page. That folder is now the home of everything you build.

  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the crt-vhs-page folder on your Desktop, then click Open.

Asked whether you trust the authors?

VS Code shows a trust prompt the first time it opens a folder. Click Yes, I trust the authors.

You created this folder yourself a moment ago, so there is nothing in it to distrust.

The Explorer sidebar on the left now shows your folder name at the top, with nothing inside it yet.

Terminal says no such file or directory?

  • Check the folder name you typed matches crt-vhs-page exactly, because terminal paths are case sensitive.
  • Move back to your Desktop, then run the mkdir command again. An earlier command may have left you in a different folder.

Still stuck? Why can't my terminal find my project folder?

Add a live preview

Screen effects are judged by eye. You'll be nudging values up and down for the rest of this project, so refreshing the browser by hand every time would slow that loop to a crawl.

A Live Server extension serves your folder and reloads the page the instant you save.

  • Click the Extensions icon in the left sidebar of VS Code.
  • Type Live Server into the search box at the top of the panel.
  • Click Install on the first result, named Live Server.

Before you look, what do you expect to change in the window once the install finishes?

Look at the status bar along the bottom of the VS Code window. A Go Live button has appeared on the right of it.

No Go Live button?

  • Give the install a few seconds to finish. The button appears once the Install button has turned into a gear icon.
  • Check that your crt-vhs-page folder is still open in the Explorer sidebar, because the button needs an open folder.

Still nothing? Why is the Go Live button missing from my status bar?

Where the effects come from

You won't be writing the particle, VHS and CRT effects from scratch. They come from two small browser effect component libraries.

You'll copy components from canvasui.dev and threeui.com in the steps ahead.

Both were used together in the source post this project is based on.

Your workspace is ready and watching for changes. Next up, you'll build the plain page that all three effects get layered onto.

Scaffold Your Page

Screen effects only read as effects when there is something plain underneath them. A particle field floating over nothing is just a screensaver.

So the first job is a page so plain it is boring. You build a heading, a paragraph, and one image, then serve it locally so you can watch it change as each effect lands on top.

In this step, get ready to:
  • Create the index.html, styles.css, and script.js files your page needs.
  • Write a page with a heading, a paragraph, and one image.
  • Serve the page locally and see it with zero effects applied.
Create your three page files

Every effect you add later arrives as either markup, styling, or a script. Creating all three files now means you never have to stop and wire up a new file mid-effect.

  • In the Explorer sidebar in VS Code, hover over the name of the project folder you set up earlier.
  • Click the New File icon that appears next to the folder name.
  • Type index.html and press Enter.
  • Repeat those two actions for styles.css.
  • Repeat them once more for script.js.

Your Explorer sidebar now lists index.html, script.js, and styles.css inside your project folder.

Files landing in the wrong place?

If a file appears outside your project folder, the folder was not selected when you clicked the icon. Delete the stray file, click your folder name once to select it, then try again.

My new files keep appearing outside my project folder in VS Code. Why does this happen and how do I fix it?

Build the page skeleton and serve it

Effects libraries need a real server to load from, and opening a file straight off disk will trip them up later. Get the server running first, then build on top of it.

  • Right-click index.html in the Explorer sidebar.
  • Click Open with Live Server to start the Live Server dev server you set up earlier.

A browser tab opens on its own showing a blank white page, with a local address in the URL bar. Blank is correct. Your HTML file is still empty.

Leave the server running

The server watches your files and reloads the browser every time you save. Keep the tab open beside your editor for the rest of the project so every change shows up on its own.

  • Give your page a heading and a paragraph by pasting this into index.html:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Screen Effects</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <h1>Signal Lost</h1>
    <p>No effects yet. This is the baseline every effect gets measured against.</p>
    <script src="script.js"></script>
  </body>
</html>

What does this code do?

  • The link tag hooks up your empty styles.css now, so every styling effect you add later lands without you touching this file again.
  • The h1 and p tags give you real body text. That text is what you judge readability against when you tune the scanlines later.
  • The script tag sits at the bottom of body so the browser has already built the page before your script runs.
  • Save index.html.
  • Switch to the browser tab your server opened. You should see Signal Lost in large black text with a single line of paragraph text underneath it.

Browser still showing a blank page?

Unsaved changes never reach the server, so check the tab title in VS Code no longer shows an unsaved dot.

If the address bar shows a path starting with file://, you opened the file directly instead of through the server. Close that tab and start the server again.

My page is still blank after saving my HTML and running a local dev server. What should I check?

Add the image you will treat later

The VHS effect gets applied to one image only, so the page needs a real photo rather than a coloured box. Pick something with faces or detail, because that is where the distortion reads clearest.

  • In Finder (macOS) or File Explorer (Windows), find a JPG photo you want to run the VHS effect on.
  • Copy the photo into your project folder.
  • In the Explorer sidebar in VS Code, rename the copied photo to photo.jpg.
  • In index.html, find this line near the bottom of the file:
    <script src="script.js"></script>
  • Add an img tag directly above it, so the two lines now look like this:
    <img src="photo.jpg" alt="The photo you will treat with the VHS effect later" />
    <script src="script.js"></script>

Before you save, predict what the page will look like. Where will the photo sit, and how big will it be?

  • Save index.html and switch back to your browser tab.

The photo drops in below the paragraph at its full natural size. Nothing is cropped, styled, or animated.

Why the plain version is worth keeping in mind

This is the only time in the project you will see the page untouched. Every judgement call ahead is a comparison against what is on your screen right now.

Note how sharp the heading looks and how easily you can read the paragraph. Those two things are what the CRT and VHS effects will start costing you.

✔️ Awesome, I've got everything!

Great, double check you have saved index.html and move on.

ⓧ I'd like to double check the full code

Here is the complete index.html to compare against.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Screen Effects</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <h1>Signal Lost</h1>
    <p>No effects yet. This is the baseline every effect gets measured against.</p>
    <img src="photo.jpg" alt="The photo you will treat with the VHS effect later" />
    <script src="script.js"></script>
  </body>
</html>

Your styles.css and script.js files stay empty until the next step.

Seeing a broken image icon?

The filename must match exactly, including the extension. A file named Photo.JPG will not load from photo.jpg on most servers.

Check the photo sits in the same folder as index.html rather than in a subfolder.

My image shows a broken icon instead of my photo. How do I work out what is wrong?

Your baseline is on screen and completely untouched. Next up, you drop a particle background behind it and watch your text float over moving pixels.

Add a Particle Background

Your page loads and everything is where you put it. It also looks like every other generated page on the internet, because nothing on it has any texture.

The cheapest fix is a field of particles drifting behind your content. The effect libraries from the source post hand you that as a component you copy in.

The awkward part is stacking. Paste the component in the wrong place and it either covers your text or never shows up at all.

In this step, get ready to:
  • Add a full page layer that sits behind your heading, paragraph, and image.
  • Copy a particle background component from one of the effect libraries in the source post.
  • Confirm the particles animate underneath your text.
Add a layer behind your content

A background effect needs somewhere to live that never pushes your content around. A layer pinned to the viewport does that job.

  • Switch back to your code editor.
  • Open index.html.
  • Find the opening <body> tag.
  • Create your effect layer by adding this as the first thing inside <body>:
<div id="particles"></div>
  • Open styles.css in your code editor.
  • Pin the layer to the viewport by adding these rules:
/* Light text so content stays readable on a dark backdrop */
body {
  color: #f5f5f5;
}

/* Full viewport layer that sits behind everything else */
#particles {
  position: fixed;
  inset: 0;
  z-index: -1;
  background: #05050a;
}

What does this layer do?

  • position: fixed takes the layer out of the page flow, so it never moves your heading, paragraph, or image.
  • inset: 0 stretches it to all four edges of the viewport.
  • z-index: -1 pushes it underneath your content instead of over it.
  • background: #05050a gives the particles a dark backdrop to show up against.
  • Save index.html and styles.css.
  • Refresh the page in your browser.

The page turns near black, and your heading, paragraph, and image now read as light text on a dark layer.

Page still white?

  • Check the id in your HTML is particles and the CSS selector is #particles. Ids are case sensitive.
  • Check that styles.css is still linked in the <head> of your page.
  • Check nothing else in your stylesheet sets a background on body. A background there paints over a layer sitting at z-index: -1.
  • Why is my background layer not showing up?
Copy a particle component from the effect library

You now have an empty stage. The effect libraries from the source post are where you get something to put on it.

  • Open canvasui.dev in a new browser tab.
  • Find a particle or background component in the library's component list.
  • Copy the component's code using the site's own copy control.

What will the library hand you?

Background components usually arrive in one of two shapes. Some are a piece of markup plus a block of JavaScript that draws into it.

Others are a single script that creates its own canvas and mounts it into an element you name. Read the snippet before you paste it so you know which one you have.

  • Switch back to your code editor.
  • Paste any markup the component gives you inside your effect layer, so it looks like this:
<div id="particles">
  <!-- the library's component markup goes here -->
</div>
  • Paste any JavaScript the component gives you into script.js.
  • If the snippet is a <script> tag instead, add it just above your closing </body> tag.
  • If the component asks for a target element or selector, point it at #particles.

Before you refresh, make a call. Do you think the particles will land behind your text or in front of it?

  • Save index.html and script.js.
  • Refresh the page in your browser.

Particles drift across the dark layer and keep moving. Depending on the component, they may sit over your heading rather than under it, which you fix next.

No particles at all?

  • Open your browser's developer console and look for a red error. A missing script file or an unsupported WebGL context both fail silently on the page but report there.
  • Check the selector the component mounts into matches #particles exactly.
  • Check your <script> tag sits below your <div id="particles"> layer, so the element exists before the script runs.
  • Why is my particle component not rendering?
Keep the particles behind your text

Effect components often size and position their own canvas. Left alone, that canvas can escape your layer and cover your content.

  • Return to styles.css in your code editor.
  • Trap the component's canvas inside your layer by adding this rule below your #particles rule:
/* Keep the component's canvas inside the layer, at full size */
#particles canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

Why this keeps the effect in its place

  • position: absolute anchors the canvas to your layer, so it inherits the layer's z-index: -1 instead of floating over your text.
  • width: 100% and height: 100% stop the canvas collapsing to a sliver, which is the most common reason particles appear in a thin band.

Before you refresh, predict which one wins the stack. Your heading, or the particles?

  • Save styles.css.
  • Refresh the page in your browser.

Your heading, paragraph, and image stay fully readable. The particles keep moving continuously behind them, edge to edge.

✔️ Awesome, I've got everything!

Great, double check both files are saved and move on.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these pieces is in place, in order:

  • The <div id="particles"></div> layer as the first thing inside <body> in index.html.
  • The body and #particles rules in styles.css.
  • The library's component markup and script, pasted where the library's own instructions said.
  • The #particles canvas rule at the bottom of styles.css.

Your page has motion behind it now. Next up, you'll put a VHS treatment on one image and compare it against the untouched version to see what it actually changed.

Apply the VHS Effect to Your Image

Your particles are already moving behind the heading and paragraph. This next effect is a different animal, because a VHS treatment is only ever meant to land on one element.

Stretched across a whole page, VHS distortion smears your body text and the page stops being readable. In this step you scope it to your image, and you keep the untreated original beside it so you can see exactly what changed.

In this step, get ready to:
  • Put a second, untreated copy of your image on the page as a control.
  • Copy a VHS effect component from the effect library you used for your particle background.
  • Scope the effect to one image so your heading and paragraph stay clean.
Frame your image with an untreated copy beside it

After a few minutes of staring at an effect, you stop seeing the original. A copy of the untouched image sitting right next to the treated one keeps that judgement honest.

  • In index.html, find the <img> tag you added when you scaffolded the page.
  • Build the comparison pair by replacing that single tag with this block:
<div class="compare">
  <figure class="compare-item">
    <img class="original" src="[[IMAGE_FILE="your image file name"]]" alt="Original image">
    <figcaption>Original</figcaption>
  </figure>
  <figure class="compare-item">
    <img class="vhs-target" src="[[IMAGE_FILE="your image file name"]]" alt="VHS treated image">
    <figcaption>VHS</figcaption>
  </figure>
</div>

What does this code do?

  • The compare wrapper holds both copies so you can lay them out as a pair.
  • The first figure stays untouched for the rest of the project. That is your reference.
  • The vhs-target class is the hook the VHS component attaches to later.
  • Save index.html.
  • Refresh your browser. You should see two identical copies of your image stacked one above the other, each with a caption underneath.

Only seeing one image?

  • Check that your old <img> tag is gone. It is easy to paste the new block above it and leave the original in place.
  • Check the file name you typed into both src values. A broken path shows as an alt text placeholder instead of a picture.
  • Ask for help: Why is only one of my two images showing up?
  • Sit the two copies next to each other by adding this to the bottom of styles.css:
/* Lay the two copies out as a row so the difference is easy to read */
.compare {
  display: flex;
  gap: 24px;
  flex-wrap: wrap;
}

/* Let each copy shrink rather than overflow the page */
.compare-item {
  flex: 1 1 240px;
  margin: 0;
}

/* Keep both images the same size as their column */
.compare-item img {
  width: 100%;
  display: block;
}

What does this code do?

  • The flex display turns the wrapper into a row, so the two figures sit beside each other instead of stacking.
  • The flex-wrap value lets the pair drop onto two lines on a narrow screen.
  • Matching widths matter here. Two copies at different sizes make the effect look stronger than it is.
  • Save styles.css.
  • Refresh your browser. The two copies now sit side by side at the same width, with a gap between them and their captions underneath.

Still stacked on top of each other?

  • Check that the class in your CSS matches the wrapper in your HTML. Class names are case sensitive.
  • Force a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear a cached stylesheet.
  • Ask for help: Why is my flex row still stacking vertically?
Copy the VHS component onto the treated image only

The VHS treatment lives in the same effect library you pulled your particle background from. Each component there ships with its own copy and install instructions, so the library page is the source of truth for the exact code.

  • Return to the effect library page you copied your particle background from in the last step.
  • Open the VHS effect component on that site.
  • Copy the component code using the library's own copy control.
  • Paste it into your project by following the install instructions shown alongside that component.
  • Set the component's target to the element with the vhs-target class, so it only touches the second copy of your image.

Why scope the effect to one element?

A VHS treatment works by distorting and shifting whatever sits underneath it. Run it over the whole page and that distortion lands on your heading and paragraph too.

Body text is always the first thing to break under an effect. Keeping the treatment on a single image buys you the atmosphere and leaves the reading surface alone.

Before you save, make a call. Which parts of the page do you expect to change once the component is running, and which should look identical?

  • Save every file the library's instructions had you edit.
  • Refresh your browser and compare what you see against your prediction.

The right hand copy now carries the VHS treatment. Your heading, your paragraph and the left hand copy are untouched.

Both copies still look identical?

  • Open your browser console and look for errors from the component. An effect that fails to start usually says so there.
  • These components render through WebGL, which silently does nothing when your browser or machine has it disabled. Check that your browser reports WebGL as available.
  • Confirm the component is pointed at vhs-target and not at the whole page or the wrapper.
  • Ask for help: Why is my VHS effect component doing nothing?
Name what the treatment actually changed

The pair only earns its place if you use it. Reading the two copies against each other tells you which part of the effect is doing the work.

  • Look along the high contrast edges in both copies and note which one shows colour fringing.
  • Look at the flat areas of both copies and note which one carries visible grain.
  • Drag your browser window down to roughly half its width and confirm both copies stay the same size as each other.

One of those three differences is carrying most of the effect. That is the dial worth reaching for when you tune it later.

✔️ Awesome, I've got everything!

Great, double check both files are saved before you move on.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these is in place, in this order:

  • The comparison block in index.html, holding both figures and replacing your original single image tag.
  • The three layout rules at the bottom of styles.css.
  • The VHS component from the effect library, pasted per its own install instructions and targeting vhs-target.
  • The particle background from the last step, still running behind all of it.

Your image carries the VHS treatment and your text is still clean. Next up, you'll lay CRT scanlines over the whole page and find the point where they start costing you readability.

Add and Tune CRT Scanlines

Your VHS treatment is sitting next to the untreated original, so the difference it makes is right there on the page. That comparison worked because the effect stayed on one image.

Scanlines are different. They go over everything, so your heading, your paragraph and both images all end up underneath them.

That is where an effect starts costing you something. In this step you push the scanlines until your body text is genuinely hard to read, then walk them back to the strongest setting you can still live with.

In this step, get ready to:
  • Add a CRT scanline overlay across the whole page.
  • Push the scanline strength too far and screenshot it.
  • Tune the strength back until your paragraph is readable again.
Add a scanline overlay over the page

Scanlines are one element sitting on top of everything else, painted with a repeating gradient. Nothing in your existing layout has to move for it to work.

  • In index.html, scroll to the bottom of the file.
  • Find this line:
</body>
  • Add the overlay element just above it, so the end of your file looks like this:
  <div class="crt-overlay"></div>
</body>
  • In styles.css, scroll to the bottom of the file.
  • Give the overlay its pattern by adding this code:
/* One value controls how heavy the scanlines look */
:root {
  --scanline-strength: 0.25;
}

/* The overlay sits above every other layer on the page */
.crt-overlay {
  position: fixed;
  inset: 0;
  z-index: 9999;
  /* Clicks pass straight through to your content */
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, var(--scanline-strength)) 0px,
    rgba(0, 0, 0, var(--scanline-strength)) 1px,
    transparent 1px,
    transparent 3px
  );
}

What does this code do?

  • position: fixed with inset: 0 pins the overlay to the viewport, so it covers the page however far you scroll.
  • z-index: 9999 puts it above the particle background and both images.
  • pointer-events: none lets clicks pass straight through, so the overlay never blocks your content.
  • repeating-linear-gradient paints a dark band every 3px down the page. --scanline-strength is the single value that makes those bands heavier or lighter.
  • Save index.html and styles.css.
  • Refresh your browser. You should see thin dark lines running horizontally across the whole page, over your heading, your paragraph and both images.

Don't see any lines?

  • Check that the class in your styles.css rule is .crt-overlay and matches the div you added, because class names are case-sensitive.
  • Make sure the div landed inside <body> rather than after the closing tag.
  • Still nothing? Why is my CSS overlay not showing up?
Push the scanlines until the text breaks

The strength lives in one place, --scanline-strength, so you can move it without touching the gradient. Turning it up is the fastest way to find where the effect stops being decoration.

Before you change anything, take a guess. Which breaks down first when the bands get heavy, your heading or your paragraph?

  • In styles.css, find this line:
  --scanline-strength: 0.25;
  • Change the value to 0.9, so the line now reads:
  --scanline-strength: 0.9;
  • Save styles.css.
  • Refresh your browser and try to read your paragraph.

The bands have swallowed most of the page. Your heading still punches through and your paragraph is close to unreadable, which is exactly the result this setting is meant to produce.

Why the paragraph gives out first

Heading text is drawn with thick strokes, so a 1px band takes away a small slice of each letter. The shape survives.

Body text is drawn with strokes about as thin as the bands themselves. At high strength the band and the letter compete for the same pixels, and the letter loses.

  • Press Cmd+Shift+4 (macOS) or Windows+Shift+S (Windows) to start a screenshot.
  • Drag a box around your heading and your paragraph.
  • Move the saved image into your project folder.
  • Rename it crt-too-strong.png.
Tune the strength back to something readable

Readable is not a number you can look up. You find it by stepping the value down until your paragraph reads cleanly from where you normally sit.

  • In styles.css, find this line:
  --scanline-strength: 0.9;
  • Change the value to 0.12, so the line now reads:
  --scanline-strength: 0.12;
  • Save styles.css.
  • Refresh your browser and read your paragraph again.

The lines still run across the whole page. Your paragraph reads as easily as it did before you added them.

Find your own ceiling

0.12 is a safe floor rather than the right answer. Step the value up by 0.05 at a time, refreshing after each save, until your paragraph starts to feel like work.

Then drop back one step and keep that value. That is your ceiling, and it will be different on a different screen.

Nothing changes when you save?

  • Check that --scanline-strength is spelled the same in the :root block and inside the gradient, because a mismatch makes the browser ignore the value.
  • Your browser may be serving a cached stylesheet, so do a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
  • Still stuck? Why is my CSS custom property not updating?
  • Press Cmd+Shift+4 (macOS) or Windows+Shift+S (Windows) to start a second screenshot.
  • Drag a box around the same heading and paragraph.
  • Move the saved image into your project folder.
  • Rename it crt-kept.png.

One last check before you move on. Do you think the scanlines are sitting on top of the particle background, or only on top of your text?

  • Refresh your browser and watch the particle background behind your content for a few seconds.
  • Open your project folder in Finder (macOS) or File Explorer (Windows).

The scanlines hold still while the particles drift underneath them, which proves the overlay is above every layer on the page. In your project folder you should see both crt-too-strong.png and crt-kept.png.

✔️ Awesome, I've got everything!

Great, double check you've saved both index.html and styles.css.

ⓧ I'd like to double check the full code

Your stylesheet already holds the particle and VHS rules from earlier steps, so scroll up and confirm each piece from this step is present, in order:

  • The <div class="crt-overlay"></div> element sitting just above the closing body tag in index.html.
  • The :root block declaring --scanline-strength at the bottom of styles.css.
  • The .crt-overlay rule with its repeating-linear-gradient background, directly below it.
  • A readable strength value, somewhere around 0.12, rather than the 0.9 you tested with.

Your scanlines are tuned and you have the two screenshots that bracket the limit. Next up, you'll make all of this motion switch itself off for anyone who has asked their system for less of it.

Add a Reduced-Motion Fallback

You tuned your scanlines down until your body text came back. That was a judgement call about readability, and you made it with your own eyes.

Some people cannot make that call by squinting harder. Constant motion makes them dizzy or nauseous, so they turn on a system setting that asks every app to calm down.

Your page ignores that request completely right now. In this step you'll branch on it and render a still version of all three effects.

In this step, get ready to:
  • See what your page does today for someone who has already asked for less motion.
  • Freeze your CSS-driven effects with a reduced-motion block.
  • Draw your canvas effects as a single still frame instead of a loop.
Turn on reduce motion and reload your page

Your operating system already has a switch for this, and your browser already passes it through. Flip it before you write any code, so you can see exactly what a motion-sensitive visitor gets today.

macOS

  • Click the Apple menu in the top left corner.
  • Choose System Settings.
  • Click Accessibility in the sidebar.
  • Click Motion on the right.
  • Turn on Reduce motion.

Windows

  • Select Start.
  • Select Settings.
  • Select Accessibility.
  • Select Visual effects.
  • Turn off the Animation effects toggle.

Can't find the setting on your machine?

On older macOS versions the Reduce motion switch lives under Accessibility then Display instead of Motion.

You can also fake the preference from the browser. Open the Rendering tab in Chrome DevTools, find Emulate CSS media feature prefers-reduced-motion, and select prefers-reduced-motion:reduce.

My reduce motion setting is on but my browser doesn't seem to notice

Before you reload, make a call. Which of your three effects do you expect to stop on their own?

  • Reload your page in the browser.

None of them stop. Your particles drift, the VHS treatment crawls and the scanlines scroll exactly as before.

The browser knows about the request. Your page has simply never asked for it.

Freeze your CSS effects with a reduced-motion block

The way you ask is a media query. CSS can gate a whole block of rules behind the visitor's motion preference.

  • Switch back to your stylesheet in your editor.
  • Add this block at the very bottom of the file:
/* Only applies when the visitor has asked their system for less motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

What does this code do?

  • @media (prefers-reduced-motion: reduce) gates everything inside it. On a machine with the setting off, none of these rules exist.
  • The *, *::before and *::after selectors reach every element on the page, including the ones your copied components injected.
  • Cutting animation-duration to 0.01ms means the browser paints the first frame and stops. That first frame is your static version.
  • !important outranks the styles that shipped with the components, so you never have to hunt through them.
  • Save your stylesheet.
  • Reload the page. Your scanlines stop scrolling and sit still, while the particle field behind your text keeps drifting.

Scanlines still scrolling?

  • Check the stylesheet is saved. An unsaved file changes nothing the browser paints.
  • Check the query reads prefers-reduced-motion: reduce exactly. A typo inside the parentheses means the block never matches.
  • If your scanlines are painted on a canvas rather than styled in CSS, this block cannot reach them. The next substep handles those.

Why is my reduced-motion media query not applying?

Draw your canvas effects once instead of looping

Your particle field is not animated by CSS. It is redrawn frame by frame by JavaScript, so a stylesheet rule cannot touch it.

The same check is available in script form. You read the preference once, then choose which version of the effect to run.

  • Switch back to your script file in your editor.
  • Find the line near the bottom of the file that kicks off your particle animation, the call that leads into requestAnimationFrame.
  • Replace that line with this branch:
// Read the visitor's motion preference once, as the page loads
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (prefersReducedMotion) {
  // Paint a single still frame and never start the loop
  [[STATIC_FRAME="call your effect's draw function once, with no requestAnimationFrame after it"]]
} else {
  // Run the animation loop exactly as it ran before
  [[ANIMATED_LOOP="the line that used to start your animation loop"]]
}

What does this code do?

  • window.matchMedia asks the browser the same question your stylesheet asks. The string has to match the media query character for character.
  • .matches hands back true or false, which is all you need to branch on.
  • Reading the preference once at load time keeps the check out of your animation loop, where it would run sixty times a second.
  • Painting one frame keeps the texture and drops the movement. The effect is still on the page, it just holds still.
  • Save your script file.

Before you reload, decide which of your three effects you still expect to be moving.

  • Reload the page with reduce motion still turned on.

Nothing moves. Your particle field sits frozen mid-drift, the VHS image holds one still frame and the scanlines stay put.

Particles still moving?

  • Check you replaced the loop-start line rather than adding the branch above it. Leaving the old call in place starts the loop regardless.
  • Search your script for a second requestAnimationFrame call. A component that restarts itself needs the same guard.
  • Check the string inside matchMedia keeps its outer parentheses. Without them the query never matches and the branch always takes the animated path.

My canvas animation keeps running even though prefersReducedMotion is true

  • Turn the reduce motion setting back off in your operating system.
  • Reload the page one more time.

All three effects come back to life. Your page now ships two versions and the visitor's own system picks which one they get.

✔️ Awesome, I've got everything!

Great. Double check both files are saved before you move on.

ⓧ I'd like to double check the full code

Scroll up and confirm each piece is in place, in this order:

  • In your stylesheet, the @media (prefers-reduced-motion: reduce) block sits at the very bottom of the file.
  • In your script file, the prefersReducedMotion constant is declared before it is used.
  • The if branch has replaced your old loop-start line, not been added alongside it.

Your page now honours a request it was ignoring ten minutes ago. Next up, you'll deploy it and find out whether these effects hold their frame rate on a phone.

Deploy and Test on Mobile

Your reduced motion fallback works, so the page now behaves for anyone who has asked their system to calm things down. That check only proves it on your own machine.

Every effect so far has run on a desktop GPU, fed by a dev server reading files straight off your disk. A phone has far less GPU to spend, and a battery telling it to spend even less.

In this step you put the page on a public URL and load it on a phone. Frame rate collapse is the pitfall that never shows up on a desktop test.

In this step, get ready to:
  • Deploy your page to a public URL on a free static host.
  • Open that URL on your phone.
  • Judge whether the particle background, VHS effect and CRT scanlines hold their frame rate on a smaller device.
Deploy your page to a public URL

Your dev server only answers to your own machine. A phone needs an address it can reach from anywhere, which means putting the files on a host.

  • In your browser, open a new tab.
  • Go to https://app.netlify.com/login in that tab.
  • Sign in to Netlify, or create a free account if you do not have one.

What Netlify asks you for

Signing up offers a few routes, including an email address or a Git account. Pick whichever one you already have.

Netlify then places you in a team. The free plan covers a page like this one, so there is nothing to upgrade.

  • Go to https://app.netlify.com/drop.
  • Drag the project folder holding your HTML page, stylesheet and script file onto the Drop here zone.

Netlify uploads the folder and publishes it at a Preview URL ending in netlify.app.

  • Record your Preview URL here: your netlify.app URL.
  • Click the Preview URL to open your deployed page.

You should see the same page your dev server was serving: particles moving behind the text, the untreated and VHS-treated images side by side, and scanlines over the whole thing.

Deployed page not showing your work?

  • If you get a file listing instead of your page, you dropped the folder one level above your project. Drop the folder that has your HTML page sitting directly inside it.
  • If the image is missing, it lives outside the folder you dropped. Move it in beside your HTML page and drag the folder onto the drop zone again.

Why do my effects render locally but not on the deployed page?

Open the deployed page on your phone

Every judgement you have made so far came off a desktop GPU. A phone renders the same three effects on a fraction of that budget.

  • Send your netlify.app URL to yourself in a message or an email.
  • Pick up your phone and find that message.

Before you tap the link, make a call. Which of your three effects do you think buckles first on a phone, and why that one?

  • Tap the link to load your deployed page.

The page loads with all three effects running. Whether they run well is the question the rest of this step answers.

Judge whether the effects hold their frame rate

A dropped frame rate does not announce itself. It arrives as motion that quietly stops looking continuous, so you have to know what to look for.

  • Watch the particle background for a slow count of ten.
  • Scroll the page down and back up at a steady speed.
  • Look at the VHS-treated image while you scroll.
  • Notice whether the back of the phone is warming up.

What a dropped frame rate looks like

  • Particles moving in visible steps instead of a smooth glide.
  • Scrolling that arrives a beat behind your finger.
  • Scanlines that crawl or tear rather than sitting still.
  • A phone that heats up while the page is only sitting there.

✔️ It runs smoothly

Your effect stack fits inside a phone's budget. Write down which handset you tested on, because an older device has far less room than a current one.

ⓧ It stutters or the phone heats up

You have found the edge of what this page can afford. Finding that edge is the whole reason for testing on the smaller device.

The particle background is the usual first suspect, because it redraws every particle on every frame.

  • Note which effect you were watching when the stutter appeared.
  • Note whether it appeared while scrolling or while the page was still.

You now have an answer to the question this step set: whether the effects hold their frame rate away from your desktop.

Secret mission

Give Visitors an Effects Off Switch

Your effects run for everyone who loads the page, whether they want them or not. Add a switch that lets a visitor turn them off, route it through the reduced motion code you already wrote, and store the choice so the page loads their way next time.

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. The effect libraries are free and your page is a static site on a free hosting tier, so there are no ongoing costs.

Resources you used:

  • Your local project folder with the HTML page, stylesheet, and script file.
  • The particle background, VHS effect, and CRT scanline overlay copied into those files.
  • The deployed page and its public URL on your hosting provider.
  • The two CRT strength screenshots from Step 4.

Keep everything running

No action needed. Choose this if you're still actively building on the page or want the deployed URL live to show people.

  • Leave your project folder and its HTML, CSS, and JavaScript files where they are.
  • Leave the deployment live at your public URL.
  • Keep the two Step 4 screenshots somewhere you'll find them again.

Keep the screenshots even if you delete everything else

The pair of CRT screenshots is the record of where the effect stopped helping. That judgement is the part worth keeping.

Pause - I'll come back to this later

Stop the animations from running in the background while keeping every file and the deployment intact.

  • Close the browser tab showing your deployed page so the particle animation stops using your GPU.
  • Close the browser tab showing your local page for the same reason.
  • Turn your OS reduce motion setting back off if you left it on after Step 5.
  • Leave your project folder and the deployment untouched so you can pick this back up.

Delete - I don't want to use this again

Remove the deployment and the local project so nothing is left behind.

  • Sign in to your hosting provider's dashboard in the browser.
  • Open the project or site entry for your deployed page.
  • Open that project's settings page.
  • Find the delete option in the settings page and confirm the deletion.
  • Visit your public URL again to confirm it no longer loads the page.

Where the delete option lives

Every static host words this differently, so the exact label depends on your provider. It sits in the project's own settings page rather than your account settings.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Delete your project folder by running this command on macOS or Linux:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • Delete your project folder by running this command on Windows:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Confirm the folder is gone by running this command:
ls
  • Turn your OS reduce motion setting back off if you left it on after Step 5.

Still seeing your folder after deleting it?

Check you ran the delete command from your Desktop and not from inside the folder itself.

Check the folder name in the command matches the name on disk, since it is case sensitive on macOS and Linux.

Need a hand? Why is my folder still there after running rm -rf?

That's a Wrap!

That's a Wrap

Nice work. You took a plain page with a heading, a paragraph and one image, layered three screen effects onto it, and shipped it to a public URL you can open on your phone.

The part worth keeping is the judgement call in the middle. You found the point where the CRT effect stopped adding atmosphere and started eating your body text.

You've learned how to:

  • Drop a particle background component behind your page content and a VHS treatment onto a single image, so you can see exactly what each effect changed.
  • Tune CRT scanlines until your text is still readable, and capture the two screenshots that bracket where the effect stopped helping.
  • Ship the page to a public URL with a reduced motion fallback, then check the effects hold their frame rate on a phone.
  • Secret Mission: You took on the optional challenge and pushed your deployed page further than the steps required.

Ready to quiz yourself?