SCRIPT KIDDY

A cabinet of curiosities for the parts of the browser you forgot you could hack: the tab title, the favicon, the URL bar, the console, and a pile of tricks that need zero JavaScript. Look up at your tab. It's already moving.

Start poking
scroll to explore ↓
01 / Browser chrome

Effects that live outside the page

The tab strip, the favicon and the address bar are all writable from JavaScript. Nobody said you had to be tasteful about it.

Marquee tab title

JS · every browser

document.title is just a string, and strings can be rotated on a timer. | WORD | → WORD | → ORD | → RD |… Pad with a non-breaking space; browsers collapse regular ones.

| SCRIPT KIDDY
what your tab says right now
Title mode
Show the trick
const text = '| SCRIPT KIDDY\u00A0';   // nbsp: normal spaces get collapsed
let i = 0;
setInterval(() => {
  const j = i++ % text.length;
  document.title = text.slice(j) + text.slice(0, j);
}, 180);

Favicon that reacts to you

JS · not Safari

Draw on a 64×64 <canvas>, export it as a data URL, and point <link rel="icon"> at it. Default: a clock hand that makes one full turn as you scroll the page.

scroll to turn the hand
Favicon mode
Show the trick
const link = document.querySelector('link[rel="icon"]');
const c = document.createElement('canvas');
c.width = c.height = 64;
const ctx = c.getContext('2d');

addEventListener('scroll', () => {
  const el = document.documentElement;
  const p = el.scrollTop / (el.scrollHeight - el.clientHeight); // 0..1
  ctx.clearRect(0, 0, 64, 64);
  ctx.fillStyle = '#60e0b0';
  ctx.beginPath(); ctx.arc(32, 32, 30, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = '#0a0c10'; ctx.lineWidth = 6; ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(32, 32);
  ctx.lineTo(32 + 22 * Math.cos(p * Math.PI * 2 - Math.PI / 2),
             32 + 22 * Math.sin(p * Math.PI * 2 - Math.PI / 2));
  ctx.stroke();
  link.href = c.toDataURL();       // the tab updates instantly
}, { passive: true });

Progress bar in the address bar

JS · every browser

history.replaceState rewrites the URL without reloading or polluting history. So the fragment can be a scroll bar: #[=====-----]-52. Off by default because it hijacks anchor links.

#[----------]-0
turn it on, then scroll and watch the address bar
Show the trick
let last = '';
addEventListener('scroll', () => {
  const el = document.documentElement;
  const p = el.scrollTop / (el.scrollHeight - el.clientHeight);
  const n = Math.round(p * 10);
  const bar = '#[' + '='.repeat(n) + '-'.repeat(10 - n) + ']-' + Math.round(p * 100);
  if (bar === last) return;            // Safari rate-limits replaceState, so only write changes
  last = bar;
  history.replaceState(null, '', location.pathname + bar);
}, { passive: true });

It notices when you leave

JS · every browser

The Page Visibility API fires visibilitychange when you switch tabs. Swap the title and favicon while you're gone, then greet you when you're back. Go on, Ctrl+Tab away and come back.

0
times you've left · longest trip 0s
Show the trick
let left = 0;
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    left = Date.now();
    document.title = '👀 psst… come back';
  } else {
    document.title = 'SCRIPT KIDDY';
    console.log(`gone for ${(Date.now() - left) / 1000}s`);
  }
});

Tabs that know about each other

JS · needs http(s)

BroadcastChannel is a same-origin group chat between tabs. Every tab announces itself and answers roll-call, so each one can count the others. Open this page in a second tab and watch the number in the top bar.

1 open
this tab's id: …
Show the trick
const me = crypto.randomUUID();
const bc = new BroadcastChannel('census');
const peers = new Map();                       // id → last seen

bc.onmessage = ({ data }) => {
  if (data.type === 'bye') peers.delete(data.id);
  else peers.set(data.id, Date.now());
  if (data.type === 'hello') bc.postMessage({ type: 'here', id: me });
  console.log('tabs open:', peers.size + 1);
};
bc.postMessage({ type: 'hello', id: me });
addEventListener('pagehide', () => bc.postMessage({ type: 'bye', id: me }));
02 / CSS only

No JavaScript was harmed

Modern CSS can watch the scrollbar, react to checkboxes on the other side of the page, animate custom properties and open popovers, all by itself.

Scroll-driven animations

CSS · animation-timeline

animation-timeline: view() binds an animation to an element's position in the viewport instead of the clock. Each word below is its own element, lighting up as it crosses the middle of the screen. The bar at the very top of the page and every card's fade-in use the same idea. Your browser doesn't support scroll-driven animations yet, so the words are shown fully lit.

Most of a web page is a rectangle you control. But the browser hands you more than that: the tab title, the little icon next to it, the address bar, the console, even a heads-up when you leave. This page pokes at all of it, one small and mostly harmless trick at a time, and this paragraph is lit by the scrollbar, not by a script.

Show the trick
/* each word: <span class="w">word</span> */
.w {
  color: gray;
  animation: lightup linear both;
  animation-timeline: view();               /* progress = where I am in the viewport */
  animation-range: cover 38% cover 52%;     /* fire while crossing the middle */
}
@keyframes lightup { to { color: white; } }

/* the page progress bar, same trick on the root scroller */
.progress {
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root);
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

One radio button recolours the site

CSS · :has()

:has() lets an ancestor react to its descendants. html:has(#pink:checked) changes a --hue variable on the root, and every accent on the page follows. The radios below are the only "state".

look at the nav, the buttons, the progress bar, the favicon…
Show the trick
:root { --hue: 165; --accent: oklch(80% 0.17 var(--hue)); }

html:has(#hue-violet:checked) { --hue: 290; }
html:has(#hue-amber:checked)  { --hue: 75;  }
html:has(#hue-pink:checked)   { --hue: 350; }

/* the inputs can be anywhere in the document */
<input type="radio" name="hue" id="hue-pink">

Animating a custom property

CSS · @property

Custom properties normally can't be animated (the browser doesn't know they're numbers). @property gives --angle a type, so a keyframe can spin it and a conic gradient can read it.

this border is one variable
Show the trick
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }

.glow {
  border: 2px solid transparent;
  background:
    linear-gradient(var(--card), var(--card)) padding-box,
    conic-gradient(from var(--angle), var(--accent), transparent 25%,
                   transparent 50%, var(--accent) 75%, transparent) border-box;
  animation: spin 3.5s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }

Toasts and accordions, no script

HTML · popover, details

The popover attribute gives you a top-layer, light-dismiss toast for free, and @starting-style lets it animate in from display: none. <details name="…"> makes an exclusive accordion. Your browser doesn't support popover yet; the accordion still works.

👋 Hi. No JavaScript. Click anywhere to dismiss me.
Why does only one open?

Because they share a name. The browser closes the others, like radio buttons.

Is this accessible?

More than the average div soup: it's a real disclosure widget with keyboard support built in.

Can I animate it?

Yes: ::details-content plus transition-behavior: allow-discrete handles the open/close.

Show the trick
<button popovertarget="toast">Show</button>
<div id="toast" popover>Hi. No JavaScript.</div>

[popover] {
  opacity: 0; translate: 0 16px;
  transition: opacity .35s, translate .35s,
              display .35s allow-discrete, overlay .35s allow-discrete;
}
[popover]:popover-open { opacity: 1; translate: 0 0; }
@starting-style { [popover]:popover-open { opacity: 0; translate: 0 16px; } }

<details name="faq">…</details>   <!-- same name = only one open -->

Theme flip with a circular wipe

CSS + 6 lines of JS

document.startViewTransition() snapshots the page, applies your change, then animates between the two images. Here the new snapshot is revealed by a clip-path circle growing from wherever you clicked. No View Transitions here, so the theme just flips.

or use ◐ in the nav
Show the trick
btn.addEventListener('click', e => {
  const x = e.clientX, y = e.clientY;
  const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
  const flip = () => document.documentElement.classList.toggle('light');
  if (!document.startViewTransition) return flip();
  document.startViewTransition(flip).ready.then(() =>
    document.documentElement.animate(
      { clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },
      { duration: 600, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)' }
    ));
});

/* css: stop the default cross-fade so the wipe is the only thing you see */
::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
03 / Pointer

Things that follow the mouse

A few numbers from pointermove, piped into CSS custom properties. The browser does the rest.

Inverting spotlight

JS · mix-blend-mode

A white disc with mix-blend-mode: difference inverts whatever it passes over. It's fixed to the viewport and lags behind the pointer with a little easing so it feels heavy.

invert everything
hover anywhere on the page once it's on · desktop only
Show the trick
.spotlight {
  position: fixed; width: 160px; height: 160px; border-radius: 50%;
  background: #fff; mix-blend-mode: difference; pointer-events: none;
}

let tx = 0, ty = 0, x = 0, y = 0;
addEventListener('pointermove', e => { tx = e.clientX; ty = e.clientY; });
(function loop() {
  x += (tx - x) * 0.18;  y += (ty - y) * 0.18;          // ease toward the pointer
  spot.style.translate = `${x - 80}px ${y - 80}px`;
  requestAnimationFrame(loop);
})();

3D tilt with a moving glare

JS · perspective

Pointer position inside the card becomes two rotation angles and a highlight position. Four custom properties, one transform.

hover me
Show the trick
.tilt {
  transform: perspective(700px) rotateX(var(--rx)) rotateY(var(--ry));
}
.tilt::after {   /* the glare */
  background: radial-gradient(240px circle at var(--gx) var(--gy), #fff5, transparent 60%);
}

card.addEventListener('pointermove', e => {
  const r = card.getBoundingClientRect();
  const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
  card.style.setProperty('--rx', `${(0.5 - py) * 18}deg`);
  card.style.setProperty('--ry', `${(px - 0.5) * 18}deg`);
  card.style.setProperty('--gx', `${px * 100}%`);
  card.style.setProperty('--gy', `${py * 100}%`);
});

Custom cursor

JS · cursor: none

Hide the real pointer with cursor: none and draw your own: a lagging ring that grows over links, a full-screen crosshair with coordinates, a comet trail on a canvas, or a rocket that turns to face where you're going. The last mode needs no JS at all: any SVG data URL can be a cursor image.

pick a mode
then hover these: a link · · desktop only
Cursor mode
Show the trick
/* hide the real pointer everywhere */
html.custom-cursor, html.custom-cursor * { cursor: none !important; }

/* a dot that follows exactly and a ring that lags behind it */
let x = 0, y = 0, rx = 0, ry = 0;
addEventListener('pointermove', e => {
  x = e.clientX; y = e.clientY;
  dot.style.translate = `${x}px ${y}px`;
  ring.classList.toggle('hover', !!e.target.closest('a, button'));   // grow over links
});
(function loop() {
  rx += (x - rx) * 0.2;  ry += (y - ry) * 0.2;
  ring.style.translate = `${rx}px ${ry}px`;
  requestAnimationFrame(loop);
})();

/* a rocket that faces the direction of travel */
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
emoji.style.rotate = `${angle + 45}deg`;     // 🚀 points up-right by default

/* or skip the JS entirely: any SVG can be the cursor. "4 3" is the hotspot. */
html { cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32'>…</svg>") 4 3, auto; }

Magnetic buttons

JS · translate

Each button sits in an invisible padded zone. Inside the zone it's pulled toward the pointer; on the way out it snaps back with an overshooting easing curve.

Show the trick
zone.addEventListener('pointermove', e => {
  const r = btn.getBoundingClientRect();
  const dx = e.clientX - (r.left + r.width / 2);
  const dy = e.clientY - (r.top + r.height / 2);
  btn.style.translate = `${dx * 0.4}px ${dy * 0.4}px`;
});
zone.addEventListener('pointerleave', () => { btn.style.translate = '0 0'; });

.magnet { transition: translate .55s cubic-bezier(.2, 1.5, .4, 1); }  /* overshoot = springy */
04 / Text

Letters behaving badly

Two ways to make a heading look like it came from a broken terminal.

Decode / scramble

JS · requestAnimationFrame

Every character gets a random reveal time. Until then it shows a random glyph. The page title did this when you arrived.

ACCESS GRANTED
Show the trick
function scramble(el, text, duration = 1200) {
  const glyphs = '!<>-_\\/[]{}=+*^?#%&@';
  const reveal = [...text].map(() => Math.random() * duration);   // per-char reveal time
  const start = performance.now();
  (function tick(now) {
    const t = now - start;
    el.textContent = [...text].map((ch, i) =>
      ch === ' ' ? ' ' : t >= reveal[i] ? ch : glyphs[Math.random() * glyphs.length | 0]
    ).join('');
    if (t < duration) requestAnimationFrame(tick);
  })(start);
}

Glitch

CSS · clip-path

Two pseudo-elements copy the text with content: attr(data-text), get tinted, and are sliced by keyframed clip-path: inset() values with steps(1) so they jump instead of slide.

SYSTEM FAILURE
Show the trick
<span class="glitch" data-text="SYSTEM FAILURE">SYSTEM FAILURE</span>

.glitch { position: relative; }
.glitch::before, .glitch::after {
  content: attr(data-text); position: absolute; inset: 0;
}
.glitch::before { color: hotpink; left: 3px;  animation: g1 2.4s steps(1) infinite; }
.glitch::after  { color: cyan;    left: -3px; animation: g2 1.9s steps(1) infinite; }
@keyframes g1 {
  0%  { clip-path: inset(12% 0 70% 0); }
  10% { clip-path: inset(60% 0 8% 0); }
  20% { clip-path: inset(30% 0 50% 0); }
  /* … a few more random slices … */
  60%, 100% { opacity: 0; }     /* rest between bursts */
}
05 / Easter eggs

For the people who look

Rewards for typing the right thing, opening the right panel, and pressing the wrong button.

Konami code

JS · keydown

Keep the last ten keys in an array and compare. On a match: a barrel roll pivoting around the centre of your viewport, and a short disco on the accent hue.

↑↑↓↓←→←→BA
type it · or cheat with the button
Show the trick
const code = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
let typed = [];
addEventListener('keydown', e => {
  const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;   // 'B' → 'b'
  typed = [...typed, k].slice(-code.length);
  if (typed.join() === code.join()) barrelRoll();
});

function barrelRoll() {
  document.body.style.transformOrigin = `50% ${scrollY + innerHeight / 2}px`;  // spin around what you see
  document.body.animate(
    [{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
    { duration: 1200, easing: 'cubic-bezier(.6,0,.4,1)' }
  );
}

Styled console + a tiny API

JS · console %c

%c in console.log applies CSS to the message. The page also leaves a kiddy object on window so you can drive every effect from DevTools.

SCRIPT KIDDY you found the console. try:
kiddy.barrelRoll()
kiddy.disco()
kiddy.title('hello world')
kiddy.favicon('🍕')
kiddy.cursor('trail')
kiddy.edit()
open DevTools with F12 or ⌘⌥I
Show the trick
console.log(
  '%c SCRIPT KIDDY %c you found the console.',
  'background:#60e0b0;color:#0a0c10;font-weight:700;padding:4px 8px;border-radius:4px',
  'color:gray'
);
window.kiddy = { barrelRoll, disco, title, favicon, edit };

Edit the whole page

JS · designMode

document.designMode = 'on' is a one-liner from the 90s that turns the entire document into a text editor. Fix typos. Rewrite the headings. Press Esc when you're done.

this text is wrong
turn on edit mode and fix it
Show the trick
document.designMode = 'on';    // that's it. that's the trick.
addEventListener('keydown', e => { if (e.key === 'Escape') document.designMode = 'off'; });

Print surprise

CSS · @media print

A print stylesheet hides the whole site and shows something else on paper. Check the preview, then cancel. Or don't. It's your paper.

🖨️
what you get on paper is not this page
Show the trick
.print-only { display: none; }
@media print {
  body > *:not(.print-only) { display: none !important; }
  .print-only { display: block; white-space: pre; font-family: monospace; }
}