web design
COMPONENT E / DIRECTIONAL FILL BUTTON · CURSOR-AWARE WIPE
GUIDE / DIRECTIONAL FILL BUTTON

Overview

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.

When to use

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.

Spec

Tokens
--white fill, --line border, --accent focus ring
Motion
scaleX/scaleY wipe, 300-400ms, power3
A11y
focus-visible ring, :active push, reduced-motion instant
Variant
.dir-btn--ghost = CSS-only secondary

Anti-patterns

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>