---
name: vhs-effect
description: Use when you want "Retro, nostalgic, edgy" - Adds scanlines, noise, color bleed, and analog distortion.
---

# VHS Effect

> **Category:** Image / Media  -  **Personality:** Retro, nostalgic, edgy
>
> **Best use:** Music, gaming, retro brands
>
> **Ratings:** Professional 1/5 - Casual 4/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

A stack of overlays that make any image look like worn 1980s VHS tape played on a CRT: horizontal **scanlines**, drifting **static/grain**, a **chromatic-aberration / RGB-split** fringe, a slow tape **wobble**, a bright **tracking-noise band** rolling up the picture, and a faux VCR **OSD** (`▶ PLAY  SP  00:14:32`). Reach for it on music, gaming or retro-brand pages where the nostalgia *is* the point. The split is done with pure CSS blend modes; only the moving band and the dropout glitches need a few lines of JS.

## Dependencies / CDN

**None for the effect — pure CSS + vanilla JS.** The demo only loads display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=Monoton&family=VT323&display=swap" rel="stylesheet">
```

`VT323` = the blocky VCR/OSD readout, `Monoton` = the neon channel "bug", `Chakra Petch` = UI/body. The grain texture is an inline SVG `feTurbulence` data-URI — no image file needed.

## HTML

One image, three colour-channel copies, then the analog overlays and the OSD:

```html
<div class="vhs-screen">
  <div class="vhs-video">                 <!-- holds the picture -->
    <span class="vhs-chan vhs-r"></span>   <!-- red channel  (offset left)  -->
    <span class="vhs-chan vhs-g"></span>   <!-- green channel (centred)     -->
    <span class="vhs-chan vhs-b"></span>   <!-- blue channel (offset right) -->
  </div>
  <div class="vhs-scan"></div>             <!-- scanlines  -->
  <div class="vhs-grain"></div>            <!-- static     -->
  <div class="vhs-vig"></div>              <!-- vignette    -->
  <div class="vhs-track" id="vhsTrack"></div> <!-- moving tracking band (JS) -->

  <div class="vhs-osd vhs-osd-tl">&#9654; PLAY <span class="vhs-sp">SP</span></div>
  <div class="vhs-osd vhs-osd-tr">00:14:32</div>
</div>
```

## CSS

```css
/* The screen MUST clip and own a stacking context so the blends recombine
   against its own black background, not against the page. */
.vhs-screen{position:relative;overflow:hidden;isolation:isolate;background:#04050a;aspect-ratio:4/3}

/* ---- THE EFFECT: RGB split ---- */
/* Each layer is the SAME image. background-blend-mode:multiply against a pure
   R/G/B colour keeps only that channel; mix-blend-mode:screen adds the three
   channels back together. With no offset they recombine to the original image;
   offset the red & blue copies and you get the classic colour fringe. */
.vhs-video{position:absolute;inset:0;isolation:isolate;background:#000;
  --img:url("/path/to/street-01.jpg"); --ca:2.4px;          /* --ca = split distance */
  animation:vhs-wobble 7s ease-in-out infinite}
.vhs-chan{position:absolute;inset:-2%;                       /* bleed so offsets never show an edge */
  background:var(--img) center/cover no-repeat;
  background-blend-mode:multiply; mix-blend-mode:screen;
  filter:saturate(1.4) contrast(1.05)}
.vhs-r{background-color:#f00; transform:translate3d(calc(var(--ca)*-1),0,0)}
.vhs-g{background-color:#0f0; transform:translate3d(0,calc(var(--ca)*.16),0)}
.vhs-b{background-color:#00f; transform:translate3d(var(--ca),0,0)}
@keyframes vhs-wobble{0%,100%{transform:translate3d(0,0,0) skewX(0)}
  46%{transform:translate3d(1px,0,0) skewX(-.16deg)}}
@supports not (mix-blend-mode:screen){               /* graceful fallback = plain image */
  .vhs-chan{mix-blend-mode:normal;background-blend-mode:normal}
  .vhs-r,.vhs-b{display:none}.vhs-g{background-color:transparent}}

/* ---- scanlines (rolling) ---- */
.vhs-scan{position:absolute;inset:0;pointer-events:none;mix-blend-mode:multiply;opacity:.55;
  background:repeating-linear-gradient(0deg,transparent 0 2px,rgba(0,0,0,.5) 2px 4px);
  animation:vhs-scanroll 9s linear infinite}
@keyframes vhs-scanroll{to{background-position:0 132px}}

/* ---- analog static (inline SVG fractal noise, screened on top) ---- */
.vhs-grain{position:absolute;inset:0;pointer-events:none;opacity:.18;mix-blend-mode:screen;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:200px 200px;animation:vhs-grain 1.05s steps(4) infinite}
@keyframes vhs-grain{0%{background-position:0 0}50%{background-position:42px -54px}100%{background-position:22px 44px}}

/* ---- vignette ---- */
.vhs-vig{position:absolute;inset:0;pointer-events:none;
  background:radial-gradient(132% 122% at 50% 50%,transparent 54%,rgba(0,0,0,.6))}

/* ---- the moving tracking-noise band (position driven by JS) ---- */
.vhs-track{position:absolute;left:-4%;right:-4%;top:0;height:62px;pointer-events:none;
  transform:translate3d(0,-140px,0);mix-blend-mode:screen;filter:blur(.5px) contrast(1.2);
  background:
    linear-gradient(180deg,transparent,rgba(255,255,255,.24) 50%,transparent),
    repeating-linear-gradient(0deg,rgba(255,255,255,.32) 0 1px,transparent 1px 3px)}

/* OSD text stays crisp & steady above the wobbling picture */
.vhs-osd{position:absolute;z-index:9;font-family:'VT323',monospace;color:#eafcff;
  text-shadow:0 0 2px rgba(39,230,255,.75),0 2px 4px rgba(0,0,0,.85)}
.vhs-osd-tl{top:13px;left:16px}.vhs-osd-tr{top:13px;right:16px}

/* Calmer when motion is unwelcome: keep the static grade, drop band + jitter */
@media (prefers-reduced-motion:reduce){.vhs-track{display:none}}
```

## JavaScript

Only the moving band (and an optional TRACKING knob) need JS. The loop is **skipped entirely** when the user prefers reduced motion — the CSS grade alone remains as a still frame.

```js
var screenEl = document.querySelector('.vhs-screen'),
    video    = screenEl.querySelector('.vhs-video'),
    grain    = screenEl.querySelector('.vhs-grain'),
    band     = document.getElementById('vhsTrack');
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

/* Optional TRACKING knob (0..100): like a real VCR there is a sweet spot (~38).
   The further off it is, the bigger the RGB-split and the more static. */
function setTracking(v){
  var bad = Math.min(1, Math.abs(v - 38) / 58);          // 0 = clean, 1 = wrecked
  video.style.setProperty('--ca', (0.6 + bad * 7).toFixed(2) + 'px');
  grain.style.opacity = (0.08 + bad * 0.5).toFixed(3);
}

/* The tracking-noise band rolls up the picture; once in a while it jumps
   (vertical-hold roll). All of this is gated behind reduced-motion. */
if (!reduce && band) {
  var H = screenEl.clientHeight || 420, y = H, last = 0;
  addEventListener('resize', function(){ H = screenEl.clientHeight || H; });
  requestAnimationFrame(function loop(ts){
    var dt = last ? Math.min(.05, (ts - last) / 1000) : 0; last = ts;
    y -= 78 * dt;                                   // creep upward
    if (y < -110) y = H + Math.random() * 40;       // wrap
    if (Math.random() < 0.004) y = Math.random() * H; // sudden roll
    band.style.transform = 'translate3d(0,' + y.toFixed(1) + 'px,0)';
    requestAnimationFrame(loop);
  });
}
```

The full demo adds play/pause, a ticking timecode, and a brief `--ca` "dropout" burst on rewind/FF — all the same pattern (toggle a CSS variable / class, guarded by `reduce`).

## How it works

- **RGB split (the core).** Three stacked copies of one image. `background-blend-mode:multiply` against a pure `#f00` / `#0f0` / `#00f` fill collapses each copy to a single colour channel; `mix-blend-mode:screen` adds them back together. Aligned, they reconstruct the original picture exactly; nudge the red and blue copies a couple of pixels apart and you get the chromatic-aberration fringe. `--ca` is the one knob that controls split strength.
- **Scanlines** are a `repeating-linear-gradient` of transparent/black multiplied over the picture; animating `background-position` makes them roll slowly.
- **Static** is an inline SVG `feTurbulence` (grayscale) screened on top at low opacity; stepping `background-position` every ~0.25 s makes it crawl.
- **Wobble** is a tiny `translate`/`skewX` keyframe on the picture layer only, so the OSD on top stays rock-steady — exactly like a real VCR.
- **Tracking band** is a bright noisy strip whose `transform: translateY` is advanced each frame in JS, with occasional random jumps for the vertical-hold "roll".

## Customization

- **Split strength:** raise/lower `--ca` (try `0`–`8px`). Drive it from a slider, scroll, or audio level.
- **Worn vs. clean:** scale grain `opacity` and scanline alpha together; pair them like the demo's TRACKING knob (worst at the extremes, clean in the middle).
- **Scanlines:** change the `2px/4px` stops for finer/coarser lines, and the `9s` for roll speed.
- **Picture:** swap `--img`; set `aspect-ratio` to `4/3` (period-correct CRT) or `16/9`.
- **OSD:** edit the `▶ PLAY / SP / 00:14:32` text; a `VT323`/7-segment font sells the VCR look. Add a camcorder date stamp or a channel "bug".
- **Palette:** the demo leans magenta + cyan (synthwave) which also reinforces the red/blue fringe — recolour the glows and accents to taste.

## Accessibility & performance

- **Reduced motion:** the JS checks `matchMedia('(prefers-reduced-motion: reduce)')` and never starts the rAF loop; the band is also `display:none`-d in CSS. What remains is a *static* graded frame — no jitter, no rolling — which satisfies the intent without killing the aesthetic.
- **GPU cost:** blend modes and `filter` are compositor work. Keep the number of simultaneously-blended overlays small (this uses ~6) and animate only `transform`/`opacity`/`background-position` — never animate a `blur()` radius.
- **The picture is decoration.** Mark the image layers `aria-hidden="true"` and keep the real, readable content (track title, artist) as normal text with sufficient contrast — don't bake meaning into the distorted picture.
- **One texture, cached.** The grain is a single inline data-URI reused by the grain and dropout layers, so there is no extra network request.

## Gotchas

- **`isolation:isolate` + an opaque (`#000`) background on the channels' parent are mandatory.** Without them, `mix-blend-mode:screen` blends against whatever is behind the element (the page) and the picture washes out to white.
- **`background-blend-mode` ≠ `mix-blend-mode`.** The first blends an element's *own* `background-image` with its `background-color`; the second blends the *element* with the layers behind it. The effect needs both.
- **Clip the screen.** `overflow:hidden` is required to hide the `inset:-2%` channel bleed and the band when it sits off-screen.
- **Encode the SVG data-URI.** `#`, `%`, `<`, `>` must be percent-encoded (`%23`, `%25`, `%3C`, `%3E`) or the noise silently fails to load.
- **CSS can't stop a JS loop.** `prefers-reduced-motion` in CSS won't pause `requestAnimationFrame` — you must check `matchMedia(...).matches` in JS too (this catalog's shared CSS also neutralises CSS animations under reduced motion, but your loop is yours to gate).
- **Provide the `@supports not (mix-blend-mode:screen)` fallback** so unsupported engines show the plain image instead of three offset coloured ghosts.
