A vertical index of large nav items. At rest each line is dim with a hairline beneath; on hover it brightens, indents, and gains a bullet. The intro draws each separator left to right while the label types in letter by letter.
Primary navigation on a focused landing or portfolio home, four to six items. Beyond that, condense labels or move to a menu. Keep each label to one word so the typing reveal reads clean.
Do not animate the indent with width, use padding-left. Do not split text with innerHTML, build spans safely. The bullet is a hover reward, not a constant.
<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->
<style>
:root{
--black:#050505;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
--line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
--font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
--dur-fast:150ms;--dur-base:300ms;--ease-out:cubic-bezier(.16,1,.3,1);
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display);min-height:100vh;padding:64px 48px}
.nav-list{list-style:none;max-width:640px;margin:0 auto;visibility:hidden}
.nav-list li{
position:relative;padding:16px 0;
font-size:clamp(1.5rem,2.5vw,2rem);font-weight:500;color:var(--grey-dim);cursor:pointer;
transition:color var(--dur-base) var(--ease-out),padding-left var(--dur-base) var(--ease-out);
}
.nav-list li .sep{position:absolute;left:0;bottom:0;width:100%;height:1px;background:var(--line);display:block;transform-origin:left center}
.nav-list li::before{
content:"●";font-size:10px;position:absolute;left:0;top:50%;transform:translateY(-50%);
opacity:0;transition:opacity var(--dur-base) var(--ease-out);color:var(--white);
}
.nav-list li:hover{color:var(--white);padding-left:26px}
.nav-list li:hover::before{opacity:1}
</style>
<ul class="nav-list" id="list">
<li><span class="txt">Work</span><i class="sep"></i></li>
<li><span class="txt">Writing</span><i class="sep"></i></li>
<li><span class="txt">About</span><i class="sep"></i></li>
<li><span class="txt">Contact</span><i class="sep"></i></li>
</ul>
<script>
/* Ordered schema:
0. invisible until 0.75s
1. hairline separators expand left → right, top row first
2. text types in letter-by-letter alongside its separator
Hover behavior (white / indent / bullet) is pure CSS from the kit. */
const list = document.getElementById("list");
const items = gsap.utils.toArray(list.querySelectorAll("li"));
// split each label into per-character spans (safe DOM building, no innerHTML)
items.forEach(li => {
const txt = li.querySelector(".txt");
const chars = txt.textContent.split("");
txt.textContent = "";
chars.forEach(c => {
const s = document.createElement("span");
s.className = "ch";
s.textContent = c;
txt.appendChild(s);
});
});
function listIntro(){
const tl = gsap.timeline({delay:0.75, defaults:{ease:"power2.inOut"}});
tl.set(list, {autoAlpha:1});
items.forEach((li, i) => {
tl.fromTo(li.querySelector(".sep"), {scaleX:0}, {scaleX:1, duration:0.6}, i * 0.15)
.from(li.querySelectorAll(".ch"), {autoAlpha:0, duration:0.02, stagger:0.05, ease:"none"}, "<");
});
return tl;
}
let tl = null;
const mm = gsap.matchMedia();
mm.add({
full: "(prefers-reduced-motion: no-preference)",
reduce: "(prefers-reduced-motion: reduce)"
}, (ctx) => {
if (ctx.conditions.full) tl = listIntro();
else gsap.set(list, {autoAlpha:1});
});
</script>