A primary action button whose fill wipes in from whichever edge the cursor entered, so the activation feels directed by the pointer rather than applied uniformly. The label inverts to black through stacked layers, so no color is tweened, only transform and opacity.
One primary CTA per viewport: hero "view work", contact "start a project", resume download. Pair with the ghost variant for the secondary action. Reserve the accent token variant for a single high-stakes action per page.
Do not stack two primary fills on one row. Do not tween text color, layer it. Keep labels to 1-3 words so they never wrap.
<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->
<style>
:root{
--black:#050505;--surface:#0C0C0C;--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}
.btn-row{display:flex;gap:24px;flex-wrap:wrap;align-items:center;justify-content:center}
.dir-btn{
position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;
padding:16px 32px;border:1px solid var(--line);background:transparent;cursor:pointer;
font-family:var(--font-mono);font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--white);
transition:transform var(--dur-fast) var(--ease-out);
}
.dir-btn:active{transform:translateY(1px)}
.dir-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.dir-fill{position:absolute;inset:0;z-index:1;background:var(--white);pointer-events:none}
.dir-label-wrap{position:relative;z-index:2;display:inline-block}
.dir-label{display:inline-block;white-space:nowrap}
.dir-label--on{position:absolute;inset:0;color:var(--black);opacity:0}
.dir-btn--ghost{border-color:var(--line-faint)}
.dir-btn--ghost .dir-label--off{color:var(--grey-dim);transition:color var(--dur-base) var(--ease-out)}
.dir-btn--ghost:hover{border-color:var(--white)}
.dir-btn--ghost:hover .dir-label--off{color:var(--white)}
</style>
<div class="btn-row">
<button class="dir-btn" type="button">
<span class="dir-fill"></span>
<span class="dir-label-wrap">
<span class="dir-label dir-label--off">View work</span>
<span class="dir-label dir-label--on">View work</span>
</span>
</button>
<button class="dir-btn dir-btn--ghost" type="button">
<span class="dir-label-wrap">
<span class="dir-label dir-label--off">Read CV</span>
</span>
</button>
</div>
<script>
const btns = gsap.utils.toArray(".dir-btn:not(.dir-btn--ghost)");
const ORIGINS = {left:"0% 50%", right:"100% 50%", top:"50% 0%", bottom:"50% 100%"};
const AXIS = {left:"scaleX", right:"scaleX", top:"scaleY", bottom:"scaleY"};
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
function edge(e, el){
const r = el.getBoundingClientRect();
const d = {left:(e.clientX-r.left)/r.width, right:(r.right-e.clientX)/r.width,
top:(e.clientY-r.top)/r.height, bottom:(r.bottom-e.clientY)/r.height};
let k="left", m=2;
for (const key in d) if (d[key] < m){ m=d[key]; k=key; }
return k;
}
function enter(btn, e){
const fill = btn.querySelector(".dir-fill");
const dur = reduce() ? 0 : 0.4;
if (e){
const ed = edge(e, btn), ax = AXIS[ed], other = ax==="scaleX"?"scaleY":"scaleX";
gsap.set(fill, {transformOrigin:ORIGINS[ed], [other]:1, [ax]:0});
gsap.to(fill, {[ax]:1, duration:dur, ease:"power3.out"});
} else {
gsap.set(fill, {transformOrigin:"50% 50%"});
gsap.to(fill, {scaleX:1, scaleY:1, duration:dur, ease:"power2.out"});
}
const ld = reduce() ? 0 : 0.2;
gsap.to(btn.querySelector(".dir-label--off"), {opacity:0, duration:ld});
gsap.to(btn.querySelector(".dir-label--on"), {opacity:1, duration:ld, delay:reduce()?0:0.12});
}
function leave(btn, e){
const fill = btn.querySelector(".dir-fill");
const dur = reduce() ? 0 : 0.3;
if (e){
const ed = edge(e, btn), ax = AXIS[ed];
gsap.set(fill, {transformOrigin:ORIGINS[ed]});
gsap.to(fill, {[ax]:0, duration:dur, ease:"power3.in"});
} else {
gsap.to(fill, {scaleX:0, scaleY:0, duration:dur, ease:"power3.in"});
}
const ld = reduce() ? 0 : 0.15;
gsap.to(btn.querySelector(".dir-label--on"), {opacity:0, duration:ld});
gsap.to(btn.querySelector(".dir-label--off"), {opacity:1, duration:ld, delay:reduce()?0:0.1});
}
gsap.set(".dir-fill", {scaleX:0, scaleY:0, opacity:1, transformOrigin:"50% 50%"});
btns.forEach(btn => {
btn.addEventListener("mouseenter", e => enter(btn, e));
btn.addEventListener("mouseleave", e => leave(btn, e));
btn.addEventListener("focusin", () => enter(btn, null));
btn.addEventListener("focusout", () => leave(btn, null));
});
</script>