BN

Component Lab

Every effect this site has used while I built it: the ones still live, the ones I tried and cut, and (where I still have it) the original reference component each was adapted from. Take whatever's useful.

Click into a card to see the effect full-size, then hit Get code for the real thing: not a paraphrase, the exact file this site runs (or used to).

Foundations

Colors, type, and spacing: the tokens every other component here reads from.

Color tokens

Two fixed palettes, one per side of the site: every component reads --bg/--text/--muted/--accent rather than hardcoding colors, so the same button or hint reads correctly on either theme.

The Grind (light)--bg: #ffffff
 --text: #14171c
 --muted: #5b6572
 --accent: #1d4ed8
The Chaos (dark)--bg: #0f1419
 --text: #e8eef7
 --muted: #9aa8b8
 --accent: #6ec1ff
View code
/* Light theme */
.theme-light {
  --bg: #ffffff;
  --text: #14171c;
  --muted: #5b6572;
  --accent: #1d4ed8;
}

/* Dark theme */
.theme-dark {
  --bg: #0f1419;
  --text: #e8eef7;
  --muted: #9aa8b8;
  --accent: #6ec1ff;
}

/* Components should read these, never hardcode a color:
   background: var(--bg);
   color: var(--text);
   border-color: var(--accent);            */

Typography

Fraunces (display, a variable font) for headings, Inter for body text. Fraunces' loaded weight range was widened to 400–900 specifically so it can genuinely interpolate on hover (see Dynamic Weight below), not just jump between two static cuts.

Hi, I'm Bhavesh.

I write backend systems for a living, and I'm a lot less organized about everything else.

View code
<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=Inter:wght@400;500;600;700&family=Fraunces:ital,opsz,wght@0,9..144,400..900;1,9..144,500&display=swap" rel="stylesheet">

:root {
  --font-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-display: "Fraunces", Georgia, serif;
}

h1, h2, h3 {
  font-family: var(--font-display);
  line-height: 1.2;
  letter-spacing: -0.01em;
}

body {
  font-family: var(--font-body);
  line-height: 1.65;
}

Spacing & easing

A five-step spacing scale and one shared easing curve, used everywhere instead of one-off values.

0.5rem · 1rem · 1.5rem · 2.5rem · 4rem

View code
:root {
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;

  --radius: 10px;
  --radius-lg: 16px;

  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  --speed: 0.18s;
}

Buttons

Three distinct button identities: the same one always leads to the same place, regardless of which page it's on.

Offset Button

Dashed pill that lifts up-left on hover with a hard offset shadow. Body matches its own page (var(--bg)/var(--text)); the shadow is always the same accent blue. That's the button's fixed "professional" signal on either theme.

View code
<a class="offset-btn" href="/wherever">
  See the résumé
  <svg class="btn-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
</a>

<style>
.offset-btn {
  --offset-shadow: #1d4ed8;

  display: inline-flex;
  align-items: center;
  gap: clamp(0.4rem, 1vw, 0.6rem);
  padding: clamp(0.6rem, 1.2vw + 0.3rem, 0.95rem) clamp(1.1rem, 2.5vw + 0.4rem, 1.85rem);
  border-radius: 999px;
  border: 2px dashed var(--text);
  background: var(--bg);
  color: var(--text) !important;
  font-weight: 600;
  font-size: clamp(0.9rem, 0.6vw + 0.75rem, 1.05rem);
  text-decoration: none;
  transition:
    transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
}

.offset-btn .btn-arrow {
  transition: transform 0.25s ease;
}

.offset-btn:hover,
.offset-btn:focus-visible {
  transform: translate(-8px, -8px);
  box-shadow: 8px 8px 0 var(--offset-shadow);
}

.offset-btn:hover .btn-arrow,
.offset-btn:focus-visible .btn-arrow {
  transform: translateX(4px);
}

.offset-btn:active {
  transform: translate(0, 0);
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
  transition-duration: 0.08s;
}

.offset-btn:focus-visible {
  outline: 3px solid var(--offset-shadow);
  outline-offset: 5px;
}

@media (prefers-reduced-motion: reduce) {
  .offset-btn, .offset-btn .btn-arrow { transition-duration: 0.01ms; }
  .offset-btn:hover, .offset-btn:focus-visible { transform: none; }
}
</style>

<!-- needs --bg/--text set on an ancestor, e.g.:
     :root { --bg: #fff; --text: #14171c; } -->

Keycap Button

A rectangular key with a solid offset "lip" standing in for its side wall: hover only glows, never moves; on :active the lip collapses and the key drops, like an actual keypress. Superseded by the Galaxy Button below, but the mechanic's worth keeping.

View code
<a class="keycap-btn" href="/wherever">The Chaos</a>

<style>
.keycap-btn {
  --keycap-fill: #12181f;
  --keycap-edge: #080b0f;
  --keycap-glow: #6ec1ff;
  --keycap-text: #cfe6ff;
  --keycap-text-hover: #ffffff;

  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 1.1rem;
  border-radius: 8px;
  background: var(--keycap-fill);
  color: var(--keycap-text) !important;
  font-weight: 600;
  font-size: 0.95rem;
  text-decoration: none;
  box-shadow: 0 4px 0 var(--keycap-edge), 0 6px 12px rgba(0, 0, 0, 0.35);
  transition: box-shadow 0.2s ease, color 0.2s ease;
}

.keycap-btn:hover,
.keycap-btn:focus-visible {
  color: var(--keycap-text-hover) !important;
  box-shadow:
    0 4px 0 var(--keycap-edge),
    0 6px 12px rgba(0, 0, 0, 0.35),
    0 0 18px 3px var(--keycap-glow);
}

.keycap-btn:active {
  transform: translateY(4px);
  box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
  transition-duration: 0.05s;
}

.keycap-btn:focus-visible {
  outline: 2px solid var(--keycap-glow);
  outline-offset: 3px;
}
</style>

Galaxy Button

Ported from a CodePen "galaxy button": one --active custom property every child reads at once: a dual radial-gradient face, a 3D-tilted star ring (real rotateX/rotateY, not a flat circle), and a two-layer masked spark rim that flips on a stepped timer. Star orbit/drift is randomized per-star by a tiny JS file.

View code
<a class="galaxy-button" href="/wherever">
  <span class="galaxy-btn-face">
    <span class="spark" aria-hidden="true"></span>
    <span class="backdrop" aria-hidden="true"></span>
    <span class="galaxy__container" aria-hidden="true">
      <span class="star star--static"></span>
      <span class="star star--static"></span>
      <span class="star star--static"></span>
      <span class="star star--static"></span>
    </span>
    <span class="galaxy" aria-hidden="true">
      <span class="galaxy__ring">
        <!-- 20x: <span class="star"></span> -->
      </span>
    </span>
    <span class="text">The Chaos</span>
  </span>
</a>
<script src="galaxy-button.js" defer></script>
View code
.galaxy-button {
  --transition: 0.25s;
  --spark: 1.8s;
  --hue: 205; /* pick your own accent hue */
  position: relative;
  display: inline-block;
  text-decoration: none;
}

.galaxy-btn-face {
  --cut: 0.08em;
  --active: 0;
  --bg:
    radial-gradient(120% 120% at 126% 126%, hsl(var(--hue) calc(var(--active) * 97%) 98% / calc(var(--active) * 0.9)) 40%, transparent 50%) calc(100px - (var(--active) * 100px)) 0 / 100% 100% no-repeat,
    radial-gradient(120% 120% at 120% 120%, hsl(var(--hue) calc(var(--active) * 97%) 70% / var(--active)) 30%, transparent 70%) calc(100px - (var(--active) * 100px)) 0 / 100% 100% no-repeat,
    hsl(var(--hue) calc(var(--active) * 100%) calc(12% - (var(--active) * 8%)));
  background: var(--bg);
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.25em;
  isolation: isolate;
  overflow: hidden;
  white-space: nowrap;
  padding: 0.5rem 1.1rem;
  border-radius: 999px;
  font-size: 0.9rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  transform-style: preserve-3d;
  perspective: 100vmin;
  scale: calc(1 + (var(--active) * 0.06));
  box-shadow:
    0 0 calc(var(--active) * 2.5em) calc(var(--active) * 1.2em) hsl(var(--hue) 97% 61% / 0.5),
    0 0.05em 0 0 hsl(var(--hue) calc(var(--active) * 97%) calc((var(--active) * 50%) + 30%)) inset,
    0 -0.05em 0 0 hsl(var(--hue) calc(var(--active) * 97%) calc(var(--active) * 10%)) inset;
  transition: box-shadow var(--transition), scale var(--transition), background var(--transition);
}

.galaxy-button:hover .galaxy-btn-face,
.galaxy-button:focus-visible .galaxy-btn-face { --active: 1; }
.galaxy-button:active .galaxy-btn-face { scale: 1; }

.galaxy-btn-face .text {
  position: relative;
  translate: 2% -6%;
  color: hsl(0 0% calc(60% + (var(--active) * 26%)));
}

.galaxy-btn-face .star {
  height: calc(var(--size, 3) * 1px);
  aspect-ratio: 1;
  background: #fff;
  border-radius: 50%;
  position: absolute;
  top: 50%;
  left: 50%;
  opacity: var(--alpha, 0.6);
  transform: translate(-50%, -50%) rotate(10deg) translateY(calc(var(--distance, 60) * 1px));
  animation: galaxy-orbit calc(var(--duration, 12) * 1s) calc(var(--delay, 0) * -1s) infinite linear;
}

@keyframes galaxy-orbit {
  to { transform: translate(-50%, -50%) rotate(370deg) translateY(calc(var(--distance, 60) * 1px)); }
}

.galaxy__container {
  position: absolute;
  inset: 0;
  opacity: var(--active);
  transition: opacity var(--transition);
  mask: radial-gradient(#fff, transparent);
  pointer-events: none;
}

.galaxy-btn-face .star--static {
  top: 50%;
  left: 50%;
  max-height: 4px;
  filter: brightness(4);
  transform: translate(0, 0);
  animation:
    galaxy-drift-x calc(var(--duration, 12) * 0.1s) calc(var(--delay, 0) * -0.1s) infinite linear,
    galaxy-drift-y calc(var(--duration, 12) * 0.2s) calc(var(--delay, 0) * -0.2s) infinite linear;
}

.galaxy-button:hover .star--static,
.galaxy-button:focus-visible .star--static { animation-play-state: paused; }

@keyframes galaxy-drift-x { from { translate: -24px 0; } to { translate: 24px 0; } }
@keyframes galaxy-drift-y { from { transform: translate(0, -14px); } to { transform: translate(0, 14px); } }

.galaxy {
  position: absolute;
  width: 100%;
  aspect-ratio: 1;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  overflow: hidden;
  opacity: var(--active);
  transition: opacity var(--transition);
  pointer-events: none;
}

.galaxy__ring {
  position: absolute;
  height: 200%;
  width: 200%;
  top: 50%;
  left: 50%;
  border-radius: 50%;
  transform: translate(-28%, -40%) rotateX(-24deg) rotateY(-30deg) rotateX(90deg);
  transform-style: preserve-3d;
}

.galaxy-btn-face .spark {
  position: absolute;
  inset: 0;
  z-index: -2;
  border-radius: inherit;
  overflow: hidden;
  mask: linear-gradient(#fff, transparent 50%);
  animation: galaxy-flip calc(var(--spark) * 2) infinite steps(2, end);
}

@keyframes galaxy-flip { to { rotate: 360deg; } }

.galaxy-btn-face .spark::before {
  content: "";
  position: absolute;
  width: 200%;
  aspect-ratio: 1;
  top: 0;
  left: 50%;
  translate: -50% -15%;
  transform: rotate(-90deg);
  opacity: calc(var(--active) + 0.4);
  background: conic-gradient(from 0deg, transparent 0 340deg, hsl(var(--hue) 90% 70%) 360deg);
  transition: opacity var(--transition);
  animation: galaxy-rotate var(--spark) linear infinite both;
}

@keyframes galaxy-rotate { to { transform: rotate(90deg); } }

.galaxy-btn-face .backdrop {
  position: absolute;
  inset: var(--cut);
  z-index: -1;
  border-radius: inherit;
  background: var(--bg);
  transition: background var(--transition);
}

.galaxy-button:focus-visible .galaxy-btn-face {
  outline: 2px solid #6ec1ff;
  outline-offset: 4px;
}

@media (prefers-reduced-motion: reduce) {
  .galaxy-btn-face .star, .galaxy-btn-face .star--static,
  .galaxy-btn-face .spark, .galaxy-btn-face .spark::before { animation: none; }
}

Text Reveal Effects

Five ways to bring text onto the screen. Stock tabs are the original pasted React/Framer-Motion/GSAP components, condensed where a helper was pure boilerplate (per-prop ref syncing, alternate traversal modes); the core mechanic is complete and accurate. Ours tabs are the actual vanilla-JS files this site ships.

Pixel Drift

Text sampled into a dense field of colored particles; a cursor over the settled text carves particles outward like a black hole, a stationary cursor = a stationary void. Adapted for the homepage name with the mouse-repulsion cut entirely. Kept just the spawn-outside-canvas → converge-to-text formation.

Bhavesh

Needs .pixel-name-canvas (absolute, covers the container) plus a .pixel-name-plain text element the script measures and hides once it starts drawing.

Appear Text / Kinetic Grid

A grid of repeated copies of a word spreads and zooms in, then all but the center one fade away, leaving the survivor in place. Shipped twice: first inline in the homepage heading (deprecated), later resized into a full-screen splash that plays before the rest of the page (current).

Bhavesh

Shown here: the original inline version (name-kinetic.js). The site's current full-screen splash (intro-sequence.js) is the same grid mechanic, just built at a bigger scale with the word count computed from the viewport.

Scramble Text

Every character starts as a random same-case letter and locks to its real character in left-to-right order across a fixed duration. The reference's hover-triggered diffusion/wave re-glitching was cut on purpose: this runs on body copy meant to be read, and re-scrambling words under the cursor while someone reads would be a usability regression, not a flourish.

I write backend systems for a living, and I'm a lot less organized about everything else.

Text Emerge

Words fade in with a scale+blur, staggered from the center outward. Simple, no hover interaction. Offered as the lighter alternative to Scramble Text above; I went with Scramble Text instead, so this one was never shipped. Kept here because the mechanic (GSAP stagger.from with a blur filter) is genuinely useful on its own.

View original component
// Text Emerge — Originkit
// Originkit — defaults rewritten to match preview.
"use client";

import * as React from "react";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";

type FontStyle = React.CSSProperties;

type TransitionValue = {
    type?: string;
    duration?: number;
    delay?: number;
    ease?: string | number[];
    staggerChildren?: number;
};

type StaggerFrom = "start" | "center" | "end" | "random";
type TextTag = "p" | "span" | "div" | "section";

type Props = {
    text?: string;
    font?: FontStyle;
    color?: string;

    staggerFrom?: StaggerFrom;
    tag?: TextTag;

    transition?: TransitionValue;
};

const mapEase = (ease: TransitionValue["ease"]): string => {
    if (typeof ease !== "string") return "power2.out";

    const easeMap: Record<string, string> = {
        linear: "none",
        easeIn: "power2.in",
        easeOut: "power2.out",
        easeInOut: "power2.inOut",
        circIn: "circ.in",
        circOut: "circ.out",
        circInOut: "circ.inOut",
        backIn: "back.in",
        backOut: "back.out(1.7)",
        backInOut: "back.inOut",
        anticipate: "back.out(1.7)",
    };

    return easeMap[ease] ?? ease;
};

export default function InkdropSpread({
    text = "We believe exceptional experiences begin with clarity, evolve through creativity, and are perfected through relentless attention to detail.",
    font = {
        fontFamily: "Inter, system-ui, sans-serif",
        fontSize: "70px",
        fontWeight: 600,
        letterSpacing: "-0.025em",
        lineHeight: "1.1em",
        textAlign: "left",
    },
    color = "#ffffff",

    staggerFrom = "center",
    tag = "section",

    transition = {
        type: "tween",
        duration: 0.5,
        delay: 0,
        ease: "easeOut",
        staggerChildren: 0.03,
    },
}: Props) {
    const containerRef = useRef<HTMLElement>(null);
    const words = text.trim().split(/\s+/).filter(Boolean);
    const textAlign =
        (font.textAlign as React.CSSProperties["textAlign"]) ?? "left";

    useEffect(() => {
        if (!containerRef.current) return;

        const wordEls = containerRef.current.querySelectorAll(".word");

        gsap.killTweensOf(wordEls);

        gsap.set(wordEls, {
            clearProps: "transform,opacity,filter",
        });

        gsap.from(wordEls, {
            opacity: 0,
            scale: 0,
            filter: "blur(4px)",

            duration: transition.duration ?? 0.5,
            delay: transition.delay ?? 0,
            stagger: {
                each: transition.staggerChildren ?? 0.03,
                from: staggerFrom,
            },
            ease: mapEase(transition.ease),
        });
    }, [text, staggerFrom, transition]);

    return React.createElement(
        tag,
        {
            ref: containerRef,
            style: {
                margin: 0,
                display: "block",
                width: "100%",
                whiteSpace: "pre-wrap",
                color,
                ...font,
                textAlign,
            },
        },
        words.map((word, index) => (
            <React.Fragment key={`${word}-${index}`}>
                <span
                    className="word"
                    style={{
                        display: "inline-block",
                    }}
                >
                    {word}
                </span>
                {index < words.length - 1 ? " " : null}
            </React.Fragment>
        ))
    );
}

Text Fall

A canvas particle title: text sampled into a monospace-glyph mask, particles fall/scatter with gravity, then reassemble into the word. Runs on the résumé page's own heading. No stock reference kept for this one: the original pasted source wasn't retained verbatim, so only the shipped version is shown here.

Bhavesh Nakum

Click the canvas to trigger the fall (that's text-fall.js's own click handler; autoFall also fires it once when the heading scrolls into view).

Background & Ambient Effects

The homepage's backdrop went through several complete identities before landing on the globe. All of them still work, just unplugged.

Hero Blobs

Three blurred, drifting pastel circles with a subtle cursor-parallax shift on the whole group (--mx/--my, set on pointermove). Superseded by the glitter/globe combination, but the drift+parallax mechanic is an easy ambient-background pattern on its own.

View code
<div class="hero-blobs" aria-hidden="true">
  <div class="blob blob-a"></div>
  <div class="blob blob-b"></div>
  <div class="blob blob-c"></div>
</div>

<style>
.hero-blobs {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  transform: translate(var(--mx, 0px), var(--my, 0px));
  transition: transform 0.4s ease-out;
}
.blob {
  position: absolute;
  border-radius: 50%;
  filter: blur(70px);
  opacity: 0.4;
  animation: drift 20s ease-in-out infinite;
  will-change: transform;
}
.blob-a { width: 26rem; height: 26rem; background: #93c5fd; top: -8%; left: -10%; }
.blob-b { width: 22rem; height: 22rem; background: #fbbf24; bottom: -12%; right: -8%; animation-delay: -7s; }
.blob-c { width: 18rem; height: 18rem; background: #6ee7b7; top: 45%; left: 62%; animation-delay: -13s; }
@keyframes drift {
  0%, 100% { transform: translate(0, 0) scale(1); }
  33% { transform: translate(2rem, -2.5rem) scale(1.08); }
  66% { transform: translate(-1.5rem, 1.5rem) scale(0.95); }
}
@media (prefers-reduced-motion: reduce) {
  .blob { animation: none; }
  .hero-blobs { transition: none; }
}
</style>

<script>
// Parallax: attach to whatever container should host the effect.
host.addEventListener("pointermove", function (e) {
  var r = host.getBoundingClientRect();
  var mx = ((e.clientX - r.left) / r.width - 0.5) * 24;
  var my = ((e.clientY - r.top) / r.height - 0.5) * 24;
  host.style.setProperty("--mx", mx.toFixed(1) + "px");
  host.style.setProperty("--my", my.toFixed(1) + "px");
});
</script>

Hero Stars

A plain CSS twinkling star field: small saturated dots with a soft glow, positions hand-placed via a Liquid loop rather than randomized (stable across page rebuilds). Superseded by the canvas-based Glitter effect, which covers the same role with actual motion.

View code
<div class="hero-stars" aria-hidden="true">
  <span class="hero-star" style="left: 8%; top: 14%;"></span>
  <span class="hero-star" style="left: 18%; top: 58%; animation-delay: 0.35s;"></span>
  <!-- repeat with your own scattered left/top %s and staggered delays -->
</div>

<style>
.hero-stars { position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.hero-star {
  position: absolute;
  width: 3px; height: 3px;
  border-radius: 50%;
  background: #60a5fa;
  box-shadow: 0 0 6px 1px rgba(96, 165, 250, 0.6);
  animation: star-twinkle 3.2s ease-in-out infinite;
}
.hero-star:nth-child(3n) { background: #f59e0b; box-shadow: 0 0 6px 1px rgba(245, 158, 11, 0.55); }
.hero-star:nth-child(3n + 1) { background: #34d399; box-shadow: 0 0 6px 1px rgba(52, 211, 153, 0.5); }
.hero-star:nth-child(4n) { width: 2px; height: 2px; }
@keyframes star-twinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .hero-star { animation: none; opacity: 0.5; }
}
</style>

Glitter

Canvas warp-tunnel: small colored particles streak outward from the center, giving a sense of gentle forward motion. Ported from a React/canvas component, transparent between frames so whatever's behind it (the globe) shows through everywhere except the particles.

Markup: <canvas class="hero-glitter"></canvas>, absolutely positioned to fill its container.

Beyond Horizon

A hand-rolled WebGL shader: an ambient glow like a sunrise over a planet's edge, with haze/rim lighting that responds to cursor position. Went through a full dark-theme phase before being remapped to render correctly on a light background. See the file's own comments for the remap math. Replaced by the Globe.

Markup: .hero-glow (positioned container) wrapping .hero-glow-canvas.

Globe

A hand-rolled WebGL rotating dot-sphere, no Three.js, by explicit choice. Dots trace real continent shapes from a land mask baked in at build time (Natural Earth data, rasterized once via a Python ray-casting script, no runtime fetch), with a Bengaluru marker in amber. Drag with a mouse or trackpad to rotate; momentum decays before auto-rotate resumes. Touch doesn't drag it. On the real homepage that used to swallow the swipe gesture that opens the résumé/life pages, so touch input is ignored here and the sphere just auto-rotates on its own.

On the real site this sits position:absolute, half-clipped by an overflow:hidden hero. Try dragging it here with a mouse: same drag-to-rotate code, just laid out inline for this card. On a touchscreen it only auto-rotates, same as production.

Hero Hover Glow

A radial blue bloom behind the hero content, faded in via :has() whenever the Galaxy Button (further down this page) is hovered or focused, pure CSS, no JS. It was originally a dark dimming effect, inverted into a bloom once the homepage itself went dark for a stretch, then left in place once the Galaxy Button moved off the homepage entirely and into the résumé nav. The selector simply never matches anymore; the CSS is still real and still correct, just permanently dormant on this page.

Forced visible here since its real trigger (hovering the Galaxy Button inside .hero) doesn't exist on this page anymore. See the :has() selector in the code below.

View code
.hero-hover-glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: radial-gradient(120% 90% at 50% 65%, rgba(29, 78, 216, 0.28), transparent 70%);
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}

@supports selector(:has(a)) {
  .hero:has(.trigger-el:hover, .trigger-el:focus-visible) .hero-hover-glow {
    opacity: 1;
  }
}

Scroll Veil

A full-screen radial glow whose opacity tracked --scroll-progress, building visual anticipation as a visitor scrolled toward the (also deprecated) auto-navigate sentinel below the hero. Removed along with that whole scroll-to-enter mechanic once real visitors turned out not to commit to a long deliberate scroll. Nothing sets --scroll-progress anymore, so this permanently sits at opacity: 0.

View code
.scroll-veil {
  position: fixed;
  inset: 0;
  z-index: 5;
  background: radial-gradient(circle at 50% 100%, rgba(29, 78, 216, 0.4), transparent 70%);
  opacity: var(--scroll-progress, 0); /* set by a scroll listener, 0 → 1 */
  pointer-events: none;
}

Hints & Micro-interactions

Small, quiet signals: a lot of iteration on "just barely enough" for each of these.

Spotlight Text

A flashlight sweep: text is fully transparent at rest, and a small circle of full brightness follows the cursor via a mask-image radial-gradient tied to --spot-x/--spot-y. Two identical text layers (dim + bright) have to share every font metric: a weight mismatch between them is what "misaligned" looked like the one time it broke.

Move your cursor over the text. Try the dark-on-white demo card above too; the mask needs zero JS beyond setting --spot-x/--spot-y on pointermove.

View code
<a class="spotlight-text" href="/wherever">
  <span class="spotlight-dim">Your text here.</span>
  <span class="spotlight-bright" aria-hidden="true">Your text here.</span>
</a>

<style>
@property --spot-size {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}
.spotlight-dim, .spotlight-bright { display: block; margin: 0; font: inherit; }
.spotlight-dim { color: transparent; } /* or var(--muted) if you want a faint fallback */
.spotlight-bright {
  display: block;
  position: absolute;
  inset: 0;
  color: #12181f;
  pointer-events: none;
  --spot-size: 0px;
  transition: --spot-size 0.3s ease;
  mask-image: radial-gradient(circle var(--spot-size) at var(--spot-x, 50%) var(--spot-y, 50%), black, black 55%, transparent 100%);
}
.spotlight-text:hover .spotlight-bright,
.spotlight-text:focus-visible .spotlight-bright { --spot-size: 140px; }
</style>

<script>
if (matchMedia("(hover: hover)").matches) {
  el.addEventListener("pointermove", function (e) {
    var r = el.getBoundingClientRect();
    el.style.setProperty("--spot-x", (e.clientX - r.left) + "px");
    el.style.setProperty("--spot-y", (e.clientY - r.top) + "px");
  });
}
</script>

Page Corner

A fluttering folded-paper corner hinting at a page underneath. Two triangles clipped with clip-path: polygon(), the top one gently flutters via a rotate/scale keyframe and peels back further on hover.

View code
<a class="page-corner" href="/wherever" aria-label="Where this leads">
  <span class="page-corner-back" aria-hidden="true"></span>
  <span class="page-corner-front" aria-hidden="true"></span>
</a>

<style>
.page-corner { position: absolute; top: 0; right: 0; z-index: 3; width: 96px; height: 96px; text-decoration: none; cursor: pointer; }
.page-corner-back, .page-corner-front { position: absolute; inset: 0; clip-path: polygon(100% 0, 100% 100%, 0 0); transform-origin: 100% 0; }
.page-corner-back { background: #1c1430; box-shadow: 0 0 24px 6px rgba(110, 193, 255, 0.35); }
.page-corner-front {
  background: #ffffff;
  box-shadow: -6px 6px 16px rgba(20, 23, 28, 0.12);
  animation: corner-flutter 5s ease-in-out infinite;
  transition: transform 0.4s ease;
}
.page-corner:hover .page-corner-front,
.page-corner:focus-visible .page-corner-front { transform: scale(0.82) rotate(-8deg); }
@keyframes corner-flutter {
  0%, 100% { transform: rotate(0deg) scale(1); }
  50% { transform: rotate(-3deg) scale(0.97); }
}
@media (prefers-reduced-motion: reduce) {
  .page-corner-front { animation: none; transform: rotate(-3deg) scale(0.94); }
}
</style>

Portal Orb

A small glowing dot, real link, whose label only appears on hover/focus: the label text is always in the DOM so screen readers announce it regardless of the opacity trick. Visual dot is 16px but the tap target is 44px via an invisible ::before overlay. Was the mobile counterpart to Page Corner.

View code
<a class="portal-orb" href="/wherever">
  <span class="portal-orb-label">Where this leads</span>
</a>

<style>
.portal-orb {
  position: absolute;
  z-index: 3;
  width: 16px; height: 16px;
  border-radius: 50%;
  background: #eaf4ff;
  box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 22px 6px rgba(110, 193, 255, 0.65);
  text-decoration: none;
  cursor: pointer;
  animation: orb-pulse 3.5s ease-in-out infinite;
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.portal-orb::before { content: ""; position: absolute; inset: -14px; } /* 44px tap target */
.portal-orb:hover, .portal-orb:focus-visible {
  transform: scale(1.2);
  box-shadow: 0 0 0 6px rgba(234, 244, 255, 0.1), 0 0 32px 10px rgba(110, 193, 255, 0.9);
}
.portal-orb-label {
  position: absolute;
  right: 50%;
  bottom: 140%;
  transform: translate(50%, 6px);
  white-space: nowrap;
  background: #1c1430;
  border: 1px solid rgba(183, 155, 250, 0.35);
  color: #eaf4ff;
  font-size: 0.8rem;
  font-weight: 600;
  padding: 0.3rem 0.7rem;
  border-radius: 999px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease, transform 0.25s ease;
}
.portal-orb:hover .portal-orb-label,
.portal-orb:focus-visible .portal-orb-label { opacity: 1; transform: translate(50%, 0); }
@keyframes orb-pulse {
  0%, 100% { box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 22px 6px rgba(110, 193, 255, 0.65); }
  50% { box-shadow: 0 0 0 4px rgba(234, 244, 255, 0.08), 0 0 28px 9px rgba(110, 193, 255, 0.85); }
}
@media (prefers-reduced-motion: reduce) {
  .portal-orb { animation: none; }
  .portal-orb:hover, .portal-orb:focus-visible { transform: none; }
}
</style>

Scroll Cue

A real link (never just a decorative hint; reduced-motion/keyboard/no-JS visitors need this to actually work), styled quietly with a bobbing arrow. The glass backdrop lives on the icon and the text separately, not on one shared pill around both: a follow-up fix after direct feedback that a single wrapper was covering more of the background behind it (the gap between icon and text included) than it needed to.

View code
<a class="scroll-cue" href="/wherever">
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M12 4v14M6 13l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
  <span>keep going</span>
</a>

<style>
.scroll-cue {
  position: absolute;
  bottom: 1.5rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  color: var(--muted, #5b6572);
  font-size: 0.8rem;
  text-decoration: none;
}
/* Two separate glass shapes, not one shared pill — the gap between the
   icon and text has nothing worth protecting, so it stays clear. */
.scroll-cue svg, .scroll-cue span {
  background: rgba(255, 255, 255, 0.45);
  backdrop-filter: blur(6px);
}
.scroll-cue svg {
  display: block;
  /* The site's own global box-sizing:border-box would otherwise let the
     svg's HTML width/height attributes win and shrink the icon to fit
     inside this padding instead of growing around it. */
  box-sizing: content-box;
  padding: 6px;
  border-radius: 50%;
  animation: bob 1.8s ease-in-out infinite;
}
.scroll-cue span {
  display: block;
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
}
.scroll-cue:hover, .scroll-cue:focus-visible { color: var(--text, #14171c); }
@keyframes bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) {
  .scroll-cue svg { animation: none; }
}
</style>

Life Pull Button

A small dark circle that bulges seamlessly out of an edge rather than floating near it: the circle's own center sits exactly on the boundary, so only its left half ever renders; the boundary itself does the clipping, no clip-path needed. That silhouette is zero-width at the top and bottom and widest at the vertical middle. Exactly the shape I was asked for. This dot is also the exact starting point for the bubble-expand transition below: the two are one continuous idea, not a button plus an unrelated effect.

This card's own right edge stands in for the viewport's: the dot bulges out of this box the same way it bulges out of the real page.

View code
<a class="life-pull-btn" href="/wherever" aria-label="Pull to see the next page">
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M15 4L7 12l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
</a>

<style>
.life-pull-btn {
  position: fixed;
  top: 50%;
  right: -20px; /* half the button's own width — centers it ON the edge */
  transform: translateY(-50%);
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  padding-left: 2px; /* keeps the icon in the visible left half, not dead-center */
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #12181f;
  color: #e8eef7;
  text-decoration: none;
  box-shadow: 0 2px 14px rgba(0, 0, 0, 0.22);
  transition: transform 0.25s ease, background 0.25s ease;
}
.life-pull-btn:hover, .life-pull-btn:focus-visible {
  transform: translateY(-50%) scale(1.12);
  background: #1a232e;
}
.life-pull-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
</style>

Social Card Hover

The only interactive touch on /life/ itself, outside the shared cross-page buttons: hovering an Instagram card lifts it 2px and swaps its border to the personal theme's own accent blue. Nothing clever, just a plain transform + border-color transition, but it's the one place that page tells you something on the page is actually meant to be clicked, not just read.

View code
<div class="social-card">
  <h2>Instagram</h2>
  <p>Main account.</p>
  <a class="handle" href="https://instagram.com/wherever">@handle</a>
</div>

<style>
.social-card {
  background: var(--surface, #1a2332);
  border: 1px solid var(--border, #263041);
  border-radius: 16px;
  padding: 1.5rem;
  transition: border-color 0.18s ease, transform 0.18s ease;
}
.social-card:hover {
  border-color: var(--accent, #6ec1ff);
  transform: translateY(-2px);
}
.social-card a.handle {
  text-decoration: none;
  font-weight: 600;
  font-size: 1.05rem;
}
.social-card a.handle::after {
  content: " ↗";
  font-weight: 400;
  opacity: 0.6;
}
</style>

Dynamic Weight

Hovering the name bolds it: genuine variable-font interpolation (not synthetic bolding), which needed Fraunces' loaded weight range widened to 400–900 first. That's the entire trick: one :hover rule, one wider font-weight axis.

Hover me
View code
<!-- needs a variable-font weight range loaded, e.g.: -->
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400..900&display=swap" rel="stylesheet">

<style>
.dynamic-weight {
  transition: font-weight 0.3s ease;
}
.dynamic-weight:hover {
  font-weight: 900;
}
</style>

Transitions

How you arrive somewhere matters as much as what's there.

Sitewide View Transition

Pure CSS, no JS. One @view-transition rule opts every same-origin navigation into the browser's native Cross-Document View Transitions API; the new page wipes up into view rather than a plain cross-fade. Disabled under reduced motion automatically. This exact rule is already active on the lab page you're reading right now.

See it: go to /resume/

(use the back button to return)

View code
@view-transition {
  navigation: auto;
}

@keyframes reveal-old { to { opacity: 0.3; } }
@keyframes reveal-new {
  from { clip-path: inset(100% 0 0 0); }
  to { clip-path: inset(0 0 0 0); }
}

::view-transition-old(root) {
  animation: reveal-old 0.45s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;
}
::view-transition-new(root) {
  animation: reveal-new 0.5s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}

Dissolve ("Thanos Snap")

Click the folded corner, the page fades to black, and a canvas of small squares (sampled per real content element, not one uniform grid) blows apart to the left before the actual navigation fires. Built after being deferred for months, then cut once I actually saw it running: the fuller version wasn't what I wanted after all.

dissolve.js toggles a .dissolving class on <html> itself: the fade spans the whole document, which is why this demo runs in its own iframe rather than inline on this page.

Globe: Focus-Zoom-to-Marker

An exit transition built into the Globe (above): spin the sphere to bring a specific marker to front-center (solved analytically from its lat/lng, not searched), then zoom the camera into it until its color fills the screen. A double-requestAnimationFrame guard makes sure the final frame actually paints before anything navigates (a real Chrome-only bug: it didn't wait for this without that guard). Two later fixes keep the huge marker crisp instead of blurry: the fragment shader's soft edge tightens as the marker grows (a fixed fraction of a tiny dot's radius is invisible, but the same fraction of a screen-filling circle is a visible soft ring), and the canvas itself renders at 4x its normal resolution for the duration of the zoom, since the CSS scale that blows the whole element up to cover the viewport otherwise has to stretch a much lower-resolution source than it needs to.

View code
// Inside globe.js: spin uAngleY to the analytically-solved angle that
// puts a fixed marker position at screen-center facing the camera —
// r.x = m·sin(θ+angleY), r.z = m·cos(θ+angleY), so r.x=0 (centered) and
// r.z maximized (front-facing) exactly when angleY = -atan2(x, z).
function angleToFrontMarker() {
  var ct = Math.cos(tiltX), st = Math.sin(tiltX);
  var px = markerPos[0];
  var pz = markerPos[1] * st + markerPos[2] * ct;
  var theta = Math.atan2(px, pz);
  var base = -theta;
  var twoPi = Math.PI * 2;
  return base + Math.round((angleY - base) / twoPi) * twoPi; // shortest turn
}

window.globeFocusMarker = function (callback) {
  focusFromAngle = angleY;
  focusTargetAngle = angleToFrontMarker();
  focusCallback = callback;
  focusPhase = "spin"; // then "zoom" (shrinks camDist, grows the marker's
                       // own point size), then "held" (frozen, waits a
                       // double rAF before firing callback so the final
                       // frame is guaranteed to have painted)
};

Resume Wipe

A real full-viewport cover for the white handoff at the end of the Globe zoom above, not a fade on the globe's own canvas. Fading only the canvas used to reveal everything else still sitting in the hero (headline, subhead, glitter, the pull button, copyright) untouched, which showed up as "the landing page again for half a second" between the amber zoom and the actual navigation. This sits above everything else on the page and fades to opaque white on its own, so nothing is left showing through underneath it.

View code
<div class="resume-wipe" aria-hidden="true"></div>

<style>
.resume-wipe {
  position: fixed;
  inset: 0;
  z-index: 950; /* above absolutely everything else on the page */
  background: #ffffff;
  opacity: 0;
  pointer-events: none;
}
.resume-wipe-active {
  transition: opacity 0.3s ease;
  opacity: 1;
}
</style>

<script>
// Trigger this once whatever exit animation you're covering for has
// reached its final frame, then navigate after the fade completes.
var wipe = document.querySelector(".resume-wipe");
wipe.classList.add("resume-wipe-active");
setTimeout(function () {
  window.location.href = "/wherever";
}, 320);
</script>

Life Wipe: Dot-to-Bubble

Click the Life Pull Button (above) or swipe left on the real homepage, and a circle grows from that exact dot until it covers the whole screen, then navigates. One clip-path: circle(), not a multi-property choreography: the center point never moves, so only the radius needs to animate, computed by hand as the distance from that center to whichever screen corner is farthest away, the same idea as clip-path's own farthest-corner keyword, worked out manually since a transition needs two real numbers to interpolate between, not a keyword on one end. An earlier version split a rectangular sweep across several @property-registered custom properties for independent timing; on a browser without @property support that silently stopped animating at all, reported directly as "the transition is very fast and unable to see it happening." A single circle radius needs no such registration.

Click the dot to expand, click again to collapse: the real site expands once and navigates instead of collapsing back.

View code
// Inside hero.js: keep the circle's rest state pinned to the button's
// own center so it genuinely starts as the dot, not a separate shape.
var lifeWipe = document.querySelector(".life-wipe");
var pullBtn = document.querySelector(".life-pull-btn");

function syncLifeWipe() {
  var r = pullBtn.getBoundingClientRect();
  var cx = r.left + r.width / 2;
  var cy = r.top + r.height / 2;
  var restR = Math.min(r.width, r.height) / 2;
  lifeWipe.style.clipPath = "circle(" + restR + "px at " + cx + "px " + cy + "px)";
}
syncLifeWipe();
window.addEventListener("resize", syncLifeWipe);

function goToLife() {
  syncLifeWipe();
  lifeWipe.classList.add("life-wipe-active");
  var r = pullBtn.getBoundingClientRect();
  var cx = r.left + r.width / 2;
  var cy = r.top + r.height / 2;
  // Farthest-corner distance from the center — Pythagoras on whichever
  // horizontal gap and whichever vertical gap is bigger.
  var dx = Math.max(cx, window.innerWidth - cx);
  var dy = Math.max(cy, window.innerHeight - cy);
  var farthest = Math.sqrt(dx * dx + dy * dy);
  lifeWipe.style.clipPath = "circle(" + farthest + "px at " + cx + "px " + cy + "px)";
  setTimeout(function () {
    window.location.href = "/wherever";
  }, 700);
}