Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Team card fan hero" for my site using the Enrichr Library element at https://enrichr.dev/elements/team-card-fan-hero.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/team-card-fan-hero.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- headline: Small team,
- ghost: big results
- button: Meet the crew
- note: Eight good dogs. One tidy library. Zero filler.
- cta: Start building
- speed: 1
- parallax: 1
- fan: 1
- background: #fbf8f1
- ink: #0c1e18
- accent: #e6ff55
- ghostColor: #c9d3c4
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/team-card-fan-hero.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-tcf">
<div class="ef-tcf__grain" aria-hidden="true"></div>
<nav class="ef-tcf__nav" aria-label="Example site">
<a class="ef-tcf__logo" href="#"><svg class="ef-tcf__mark" viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg><span>Enrichr</span></a>
<ul class="ef-tcf__links">
<li class="ef-tcf__li"><a class="ef-tcf__link" href="#">Work</a></li>
<li class="ef-tcf__li"><a class="ef-tcf__link" href="#">Library</a></li>
<li class="ef-tcf__li"><a class="ef-tcf__link" href="#">Process</a></li>
<li class="ef-tcf__li"><a class="ef-tcf__link" href="#">Contact</a></li>
</ul>
<button type="button" class="ef-tcf__cta"><span class="ef-tcf__ct">Start building</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg></button>
</nav>
<div class="ef-tcf__hero">
<h2 class="ef-tcf__team"><span class="ef-tcf__sr">Small team, big results</span><span class="ef-tcf__tv" aria-hidden="true"></span></h2>
<div class="ef-tcf__bigwrap" aria-hidden="true"><div class="ef-tcf__big"></div></div>
<div class="ef-tcf__row" aria-hidden="true">
<div class="ef-tcf__card"><img src="/img/library/corgi-3.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-6.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-1.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-4.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-2.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-5.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-3.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
<div class="ef-tcf__card"><img src="/img/library/corgi-6.jpg" alt="" loading="lazy" decoding="async" draggable="false"></div>
</div>
<div class="ef-tcf__sub">
<button type="button" class="ef-tcf__pill"><span class="ef-tcf__pt">Meet the crew</span><span class="ef-tcf__ar"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg></span></button>
<p class="ef-tcf__note">Eight good dogs. One tidy library. Zero filler.</p>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-tcf { --ef-tcf-bg: #fbf8f1; --ef-tcf-ink: #0c1e18; --ef-tcf-acc: #e6ff55; --ef-tcf-ghost: #c9d3c4; --ef-tcf-soft: color-mix(in srgb, var(--ef-tcf-ink) 88%, var(--ef-tcf-bg)); --ef-tcf-mute: color-mix(in srgb, var(--ef-tcf-ink) 52%, var(--ef-tcf-bg)); position: relative; width: 100%; box-sizing: border-box; overflow: hidden; border-radius: 1rem; color: var(--ef-tcf-ink); background: radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--ef-tcf-bg) 86%, #fff) 0%, color-mix(in srgb, var(--ef-tcf-bg) 92%, #a08c5a) 60%, color-mix(in srgb, var(--ef-tcf-bg) 82%, #a08c5a) 100%); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; -webkit-font-smoothing: antialiased; container-type: inline-size; display: flex; flex-direction: column; min-height: 45rem; }
.ef-tcf *, .ef-tcf *::before, .ef-tcf *::after { box-sizing: border-box; }
.ef-tcf .ef-tcf__grain { position: absolute; inset: 0; z-index: 100; pointer-events: none; opacity: 0.06; mix-blend-mode: multiply; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
.ef-tcf .ef-tcf__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* nav */
.ef-tcf .ef-tcf__nav { position: absolute; top: 0; left: 0; right: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between; padding: 28px 48px; opacity: 0; }
.ef-tcf .ef-tcf__logo { display: flex; align-items: center; gap: 10px; color: var(--ef-tcf-ink); font-size: 16px; font-weight: 700; letter-spacing: -0.02em; text-decoration: none; }
.ef-tcf .ef-tcf__mark { display: block; width: 22px; height: 22px; }
.ef-tcf .ef-tcf__links { display: flex; gap: 30px; margin: 0; padding: 0; list-style: none; }
.ef-tcf .ef-tcf__li { margin: 0; padding: 0; list-style: none; opacity: 1; }
.ef-tcf .ef-tcf__link { position: relative; color: var(--ef-tcf-soft); font-size: 14px; font-weight: 500; text-decoration: none; transition: color 0.3s; }
.ef-tcf .ef-tcf__link:hover { color: var(--ef-tcf-ink); }
.ef-tcf .ef-tcf__link::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 0; height: 1.5px; border-radius: 2px; background: currentColor; transition: width 0.4s cubic-bezier(0.7, 0, 0.2, 1); }
.ef-tcf .ef-tcf__link:hover::after { width: 100%; }
.ef-tcf .ef-tcf__cta { display: inline-flex; align-items: center; gap: 8px; padding: 12px 22px; border: none; border-radius: 999px; background: var(--ef-tcf-ink); color: var(--ef-tcf-bg); font: 600 13px/1.2 'Parkinsans', 'Space Grotesk', sans-serif; cursor: pointer; box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.4), 0 4px 8px -2px rgba(0, 0, 0, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -2px 4px rgba(0, 0, 0, 0.4); transition: transform 0.4s cubic-bezier(0.6, 0, 0.2, 1); }
.ef-tcf .ef-tcf__cta:hover { transform: translateY(-2px); }
.ef-tcf .ef-tcf__cta:focus-visible, .ef-tcf .ef-tcf__pill:focus-visible, .ef-tcf .ef-tcf__link:focus-visible, .ef-tcf .ef-tcf__logo:focus-visible { outline: 3px solid var(--ef-tcf-ink); outline-offset: 3px; }
/* hero */
.ef-tcf .ef-tcf__hero { position: relative; z-index: 5; display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1 0 auto; padding: 130px 32px 60px; }
.ef-tcf .ef-tcf__team { position: relative; z-index: 4; margin: 0; padding: 0; opacity: 1; color: var(--ef-tcf-ink); font-family: inherit; font-weight: 700; font-size: clamp(34px, 5.2cqw, 75px); line-height: 1; letter-spacing: -0.035em; text-align: center; text-transform: none; }
.ef-tcf .ef-tcf__word { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.12em; margin-bottom: -0.12em; }
.ef-tcf .ef-tcf__word > span { display: inline-block; transform: translateY(105%); }
.ef-tcf .ef-tcf__bigwrap { position: relative; z-index: 1; display: flex; justify-content: center; width: 100%; margin-top: -20px; }
.ef-tcf .ef-tcf__big { color: var(--ef-tcf-ghost); font-family: inherit; font-style: italic; font-weight: 800; font-size: clamp(90px, 16.6cqw, 237px); line-height: 0.85; letter-spacing: -0.045em; text-align: center; white-space: nowrap; user-select: none; text-shadow: 0 4px 12px rgba(60, 80, 40, 0.04); }
.ef-tcf .ef-tcf__letter { display: inline-block; transform-origin: bottom; opacity: 0; }
/* the card row */
.ef-tcf .ef-tcf__row { position: absolute; top: 50%; left: 0; right: 0; z-index: 3; height: 320px; pointer-events: none; }
.ef-tcf .ef-tcf__card { position: absolute; overflow: hidden; border-radius: 18px; opacity: 0; pointer-events: auto; cursor: pointer; will-change: transform; background: linear-gradient(180deg, #005236 0%, #126e45 35%, #1a7a4c 70%, var(--ef-tcf-acc) 100%); box-shadow: 0 30px 50px -16px rgba(0, 61, 40, 0.5), 0 14px 26px -8px rgba(0, 52, 34, 0.32), 0 5px 10px -2px rgba(5, 13, 10, 0.2), inset 0 2px 0 rgba(251, 248, 241, 0.45), inset 0 -4px 8px rgba(0, 40, 26, 0.4); transition: box-shadow 0.5s cubic-bezier(0.6, 0, 0.2, 1); }
.ef-tcf .ef-tcf__card::before { content: ""; position: absolute; inset: 0; z-index: 2; border-radius: inherit; pointer-events: none; background: linear-gradient(155deg, rgba(255, 255, 255, 0.25) 0%, transparent 30%, transparent 70%, rgba(0, 40, 26, 0.25) 100%); }
.ef-tcf .ef-tcf__card img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; user-select: none; -webkit-user-drag: none; }
.ef-tcf .ef-tcf__card:hover { box-shadow: 0 44px 70px -20px rgba(0, 61, 40, 0.6), 0 22px 38px -10px rgba(0, 52, 34, 0.38), 0 8px 14px -3px rgba(5, 13, 10, 0.22), inset 0 2px 0 rgba(251, 248, 241, 0.5), inset 0 -4px 8px rgba(0, 40, 26, 0.4); }
.ef-tcf .ef-tcf__card:nth-child(1) { width: 130px; height: 180px; left: 4%; top: 30px; z-index: 1; }
.ef-tcf .ef-tcf__card:nth-child(2) { width: 160px; height: 220px; left: 12%; top: 50px; z-index: 2; }
.ef-tcf .ef-tcf__card:nth-child(3) { width: 200px; height: 270px; left: 22%; top: 20px; z-index: 4; }
.ef-tcf .ef-tcf__card:nth-child(4) { width: 150px; height: 200px; left: 36%; top: 70px; z-index: 3; }
.ef-tcf .ef-tcf__card:nth-child(5) { width: 230px; height: 310px; left: 44%; top: 0; z-index: 5; }
.ef-tcf .ef-tcf__card:nth-child(6) { width: 160px; height: 215px; left: 59%; top: 55px; z-index: 3; }
.ef-tcf .ef-tcf__card:nth-child(7) { width: 175px; height: 240px; left: 70%; top: 30px; z-index: 4; }
.ef-tcf .ef-tcf__card:nth-child(8) { width: 130px; height: 175px; left: 84%; top: 50px; z-index: 2; }
.ef-tcf .ef-tcf__card:nth-child(2) img { object-position: 68% 50%; }
.ef-tcf .ef-tcf__card:nth-child(4) img { object-position: 30% 50%; }
.ef-tcf .ef-tcf__card:nth-child(6) img { object-position: 60% 50%; }
.ef-tcf .ef-tcf__card:nth-child(7) img { object-position: 45% 75%; }
.ef-tcf .ef-tcf__card:nth-child(8) img { object-position: 74% 40%; }
/* button and note under the row */
.ef-tcf .ef-tcf__sub { position: relative; z-index: 4; margin-top: 180px; text-align: center; opacity: 0; }
.ef-tcf .ef-tcf__pill { display: inline-flex; align-items: center; gap: 10px; padding: 12px 20px 12px 24px; border: none; border-radius: 999px; background: var(--ef-tcf-ink); color: var(--ef-tcf-bg); font: 500 14px/1.2 'Parkinsans', 'Space Grotesk', sans-serif; cursor: pointer; box-shadow: 0 14px 24px -8px rgba(0, 0, 0, 0.4), 0 5px 10px -2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -2px 4px rgba(0, 0, 0, 0.4); transition: transform 0.4s cubic-bezier(0.6, 0, 0.2, 1); }
.ef-tcf .ef-tcf__pill:hover { transform: translateY(-2px); }
.ef-tcf .ef-tcf__ar { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: linear-gradient(180deg, var(--ef-tcf-acc), color-mix(in srgb, var(--ef-tcf-acc) 80%, #1a7a4c)); color: var(--ef-tcf-ink); transition: transform 0.4s cubic-bezier(0.6, 0, 0.2, 1); }
.ef-tcf .ef-tcf__pill:hover .ef-tcf__ar { transform: rotate(45deg); }
.ef-tcf .ef-tcf__note { margin: 22px 0 0; padding: 0; opacity: 1; color: var(--ef-tcf-mute); font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 13px; line-height: 1.4; letter-spacing: 0.06em; }
.ef-tcf .is-press { animation: ef-tcf-press 0.24s cubic-bezier(0.645, 0.045, 0.355, 1); }
@keyframes ef-tcf-press { 50% { scale: 0.93; } }
@container (max-width: 750px) {
.ef-tcf .ef-tcf__nav { padding: 18px; }
.ef-tcf .ef-tcf__links { display: none; }
.ef-tcf .ef-tcf__hero { padding: 100px 16px 40px; }
.ef-tcf .ef-tcf__team { font-size: 8.2cqw; }
.ef-tcf .ef-tcf__big { font-size: 17cqw; }
.ef-tcf .ef-tcf__row { height: 220px; top: 52%; }
.ef-tcf .ef-tcf__card:nth-child(1) { width: 80px; height: 110px; left: 0%; top: 20px; }
.ef-tcf .ef-tcf__card:nth-child(2) { width: 90px; height: 130px; left: 10%; top: 35px; }
.ef-tcf .ef-tcf__card:nth-child(3) { width: 110px; height: 150px; left: 22%; top: 15px; }
.ef-tcf .ef-tcf__card:nth-child(4) { width: 90px; height: 120px; left: 36%; top: 50px; }
.ef-tcf .ef-tcf__card:nth-child(5) { width: 130px; height: 180px; left: 46%; top: 0; }
.ef-tcf .ef-tcf__card:nth-child(6) { width: 95px; height: 130px; left: 62%; top: 40px; }
.ef-tcf .ef-tcf__card:nth-child(7) { width: 100px; height: 140px; left: 74%; top: 20px; }
.ef-tcf .ef-tcf__card:nth-child(8) { width: 80px; height: 110px; left: 87%; top: 35px; }
.ef-tcf .ef-tcf__sub { margin-top: 120px; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Team card fan hero: "Small team," over a huge faded "big results", with a fanned row of eight photo cards. One intro
// timeline plays when the hero first comes into view: the nav drops in, the headline words rise out of a mask, the ghost
// letters spring up one by one (back.out 1.6), and the cards fall from 800px above, turning 25 degrees back to their own
// tilt and growing from 70%, staggered out from the middle (back.out 1.4). Each card then floats up and down and rocks
// 1.5 degrees on its own slow sine in-out loop. The cards drift with the pointer at their own depth, tilt in 3D under it
// (spring back on an elastic ease when it leaves), squash when clicked, and the ghost letters hop when hovered. Scrolling
// the hero off the top fans the cards out, lifts and fades the headline and grows the ghost text. All maths and timings
// are the original GSAP ones, re-done with requestAnimationFrame. Reduced motion shows the finished layout, still.
// Pauses off screen and in hidden tabs.
// mount(root, { headline, ghost, button, note, cta, speed, parallax (strength), fan (scroll fan-out strength),
// background, ink, accent, ghostColor })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-tcf");
var nav = root.querySelector(".ef-tcf__nav");
var team = root.querySelector(".ef-tcf__team");
var teamVis = root.querySelector(".ef-tcf__tv");
var teamSr = root.querySelector(".ef-tcf__sr");
var bigWrap = root.querySelector(".ef-tcf__bigwrap");
var big = root.querySelector(".ef-tcf__big");
var sub = root.querySelector(".ef-tcf__sub");
var pill = root.querySelector(".ef-tcf__pill");
var cta = root.querySelector(".ef-tcf__cta");
var REST = [-9, -5, -2, 3, 0, 4, 7, -4], DEPTH = [14, 10, 8, 12, 6, 11, 9, 13];
var MOVES = [[-260, -40, -25], [-200, 20, -18], [-120, 80, -10], [-40, 120, -4], [40, 120, 4], [120, 80, 12], [200, 20, 22], [260, -40, 28]];
var cards = [].slice.call(root.querySelectorAll(".ef-tcf__card")).map(function (el, i) {
return { el: el, i: i, rest: REST[i], depth: DEPTH[i], rx: ch(0), ry: ch(0), s: ch(1), sOwned: false, base: null };
});
var opts = {}, words = [], letters = [], raf = 0, io = null, ro = null, visible = false, started = false, destroyed = false;
var tl = 0, clk = 0, last = 0, mx = 0, my = 0, tx = 0, ty = 0, scrollP = 0, offs = [], textSig = "";
// GSAP eases. power2 = cubic, power3 = quartic. back.out(s) and elastic.out(1, 0.6) as GSAP defines them.
function out3(p) { return 1 - Math.pow(1 - p, 3); }
function out4(p) { return 1 - Math.pow(1 - p, 4); }
function inOut3(p) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(2 - 2 * p, 3) / 2; }
function sineInOut(p) { return (1 - Math.cos(Math.PI * p)) / 2; }
function backOut(s) { return function (p) { if (!p) return 0; p -= 1; return p * p * ((s + 1) * p + s) + 1; }; }
var back14 = backOut(1.4), back16 = backOut(1.6);
function elastic(p) { return p >= 1 ? 1 : Math.pow(2, -10 * p) * Math.sin((p - 0.15) * (2 * Math.PI / 0.6)) + 1; }
function prog(t, start, dur) { var p = (t - start) / dur; return p <= 0 ? 0 : p >= 1 ? 1 : p; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
// a "channel" is one animated number; starting a new tween on it replaces the old one (GSAP overwrite: "auto")
function ch(v) { return { v: v, tw: null }; }
function tween(c, to, dur, ease, delay, fn) { c.tw = { start: clk + (delay || 0), dur: dur, to: to, ease: ease, fn: fn, from: undefined }; }
function step(c) {
var w = c.tw; if (!w || clk < w.start) return;
if (w.from === undefined) w.from = c.v; // like GSAP, read the start value when the tween actually begins
var p = Math.min(1, (clk - w.start) / w.dur);
c.v = w.fn ? w.fn(p) : w.from + (w.to - w.from) * w.ease(p);
if (p >= 1) c.tw = null;
}
// GSAP's stagger { each: 0.08, from: "center" }: delay grows with distance from the middle, 0 to 0.56 s over 8 cards
var DLY = (function () {
var n = cards.length, mid = n / 2 - 0.5, d = [], min = 1e9, max = 0, j;
for (j = 0; j < n; j++) { d[j] = Math.abs(j - mid); min = Math.min(min, d[j]); max = Math.max(max, d[j]); }
return d.map(function (v) { return (max - min ? (v - min) / (max - min) : 0) * 0.08 * (n - 1); });
})();
function cardIntro(c, t) {
var e = back14(prog(t, 0.8 + DLY[c.i], 1.1));
return { y: -800 * (1 - e), r: c.rest + 25 * (1 - e), o: clamp01(e), s: 0.7 + 0.3 * e };
}
// the float tween starts at 1.8 + 0.1 i s, from wherever the intro has the card at that moment (so cards that are
// still landing keep that offset, exactly as in the pen), rising 8, 13 or 18 px and rocking to rest +/- 1.5 degrees
function cardFloat(c, t, intro) {
var ts = 1.8 + 0.1 * c.i;
if (t < ts) return intro;
if (!c.base) c.base = cardIntro(c, ts);
var d = 3 + (c.i % 4) * 0.5, a = 8 + (c.i % 3) * 5, r1 = c.rest + (c.i % 2 === 0 ? 1.5 : -1.5);
var u = (t - ts) / d, n = Math.floor(u), f = u - n;
if (n % 2) f = 1 - f; // yoyo
var e = sineInOut(f);
return { y: c.base.y + a * e, r: c.base.r + (r1 - c.base.r) * e };
}
function buildText() {
teamVis.textContent = ""; big.textContent = ""; words = []; letters = [];
String(opts.headline).trim().split(/\s+/).forEach(function (w, i, all) {
var outer = document.createElement("span"); outer.className = "ef-tcf__word";
var inner = document.createElement("span"); inner.textContent = w;
outer.appendChild(inner); teamVis.appendChild(outer); words.push(inner);
if (i < all.length - 1) teamVis.appendChild(document.createTextNode(" "));
});
Array.from(String(opts.ghost)).forEach(function (chr) {
var s = document.createElement("span"); s.className = "ef-tcf__letter";
s.textContent = chr === " " ? " " : chr;
big.appendChild(s); letters.push({ el: s, h: ch(0) });
});
teamSr.textContent = String(opts.headline) + " " + String(opts.ghost);
}
function render() {
var t = reduce ? 30 : tl, sp = reduce ? 0 : scrollP * opts.fan, e, i;
e = out4(prog(t, 0.1, 0.8));
nav.style.opacity = e; nav.style.transform = "translateY(" + (-20 * (1 - e)).toFixed(2) + "px)";
for (i = 0; i < words.length; i++) words[i].style.transform = "translateY(" + (105 * (1 - out4(prog(t, 0.3 + 0.08 * i, 0.9)))).toFixed(2) + "%)";
for (i = 0; i < letters.length; i++) {
var L = letters[i]; step(L.h);
e = back16(prog(t, 0.55 + 0.05 * i, 0.9));
L.el.style.opacity = clamp01(e);
L.el.style.transform = "translateY(" + (80 * (1 - e) + L.h.v).toFixed(2) + "px)";
}
e = out4(prog(t, 1.6, 0.8));
sub.style.opacity = (e * clamp01(1 - sp * 2)).toFixed(3);
sub.style.transform = "translateY(" + (20 * (1 - e)).toFixed(2) + "px)";
big.style.transform = sp ? "scale(" + (1 + 0.15 * sp).toFixed(4) + ")" : "";
big.style.opacity = (1 - 0.4 * sp).toFixed(3);
team.style.transform = sp ? "translateY(" + (-60 * sp).toFixed(2) + "px)" : "";
team.style.opacity = clamp01(1 - sp * 1.5).toFixed(3);
for (i = 0; i < cards.length; i++) {
var c = cards[i], a = cardIntro(c, t), f = reduce ? { y: 0, r: c.rest } : cardFloat(c, t, a), m = MOVES[i];
step(c.rx); step(c.ry); step(c.s);
var s = c.sOwned ? c.s.v : a.s;
c.el.style.opacity = a.o;
c.el.style.transform = "perspective(700px) translate(" + (m[0] * sp).toFixed(2) + "px," + (f.y + m[1] * sp).toFixed(2) + "px) rotate(" + (f.r + m[2] * sp).toFixed(3) +
"deg) rotateY(" + c.ry.v.toFixed(3) + "deg) rotateX(" + c.rx.v.toFixed(3) + "deg) scale(" + s.toFixed(4) + ")";
c.el.style.translate = reduce ? "" : (tx * c.depth * opts.parallax).toFixed(2) + "px " + (ty * c.depth * 0.5 * opts.parallax).toFixed(2) + "px";
}
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = (now - last) / 1000; last = now;
if (dt > 0.5 || dt < 0) dt = 0.033; // GSAP-style lag smoothing
if (!visible || document.hidden) return;
clk += dt; tl += dt * opts.speed;
var k = 1 - Math.pow(0.95, dt * 60); // the pen eases 5% of the way to the pointer each 60 fps frame
tx += (mx - tx) * k; ty += (my - ty) * k;
render();
}
function start() {
if (started || destroyed) return;
started = true;
if (reduce) { render(); return; }
last = performance.now(); raf = requestAnimationFrame(loop);
}
// ScrollTrigger start "top top", end "bottom top": 0 while the hero's top is in view, 1 once it has scrolled past
function onScroll() { var r = box.getBoundingClientRect(); scrollP = r.height ? clamp01(-r.top / r.height) : 0; }
function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }
function isMouse(e) { return e.pointerType === "mouse" || e.pointerType === "pen"; }
function press(el) { el.classList.remove("is-press"); void el.offsetWidth; el.classList.add("is-press"); }
on(box, "pointermove", function (e) {
if (!isMouse(e)) return;
var r = box.getBoundingClientRect();
mx = ((e.clientX - r.left) / r.width - 0.5) * 2; my = ((e.clientY - r.top) / r.height - 0.5) * 2;
});
on(box, "pointerleave", function () { mx = 0; my = 0; });
on(box, "click", function (e) { var a = e.target.closest && e.target.closest("a[href='#']"); if (a) e.preventDefault(); });
on(pill, "click", function () { press(pill); });
on(cta, "click", function () { press(cta); });
cards.forEach(function (c) {
on(c.el, "pointermove", function (e) {
if (reduce || !isMouse(e)) return;
var r = c.el.getBoundingClientRect(), px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
if (!c.sOwned) { c.s.v = cardIntro(c, tl).s; c.sOwned = true; }
tween(c.rx, -py * 16, 0.4, out3); tween(c.ry, px * 16, 0.4, out3); tween(c.s, 1.12, 0.4, out3);
c.el.style.zIndex = "20";
});
on(c.el, "pointerleave", function () {
if (reduce) return;
tween(c.rx, 0, 0.8, elastic); tween(c.ry, 0, 0.8, elastic);
if (c.sOwned) tween(c.s, 1, 0.8, elastic);
c.el.style.zIndex = ""; // back to its place in the stack (the pen left hovered cards at z-index 20)
});
on(c.el, "click", function () {
if (reduce) return;
if (!c.sOwned) { c.s.v = cardIntro(c, tl).s; c.sOwned = true; }
// fromTo scale 1.15 -> 1.05, 0.15 s power2.inOut, yoyo once: ends back at 1.15
tween(c.s, 1.15, 0.3, null, 0, function (p) { return 1.15 - 0.1 * inOut3(p < 0.5 ? p * 2 : 2 - p * 2); });
});
});
on(bigWrap, "mouseenter", function () {
if (reduce) return;
letters.forEach(function (L, i) { tween(L.h, -8, 0.5, back16, 0.03 * i); });
});
on(bigWrap, "mouseleave", function () {
if (reduce) return;
letters.forEach(function (L, i) { tween(L.h, 0, 0.6, elastic, 0.03 * i); });
});
if (!reduce) on(window, "scroll", onScroll, { passive: true });
function update(partial) {
opts = Object.assign({
headline: "Small team,", ghost: "big results", button: "Meet the crew", note: "Eight good dogs. One tidy library. Zero filler.",
cta: "Start building", speed: 1, parallax: 1, fan: 1,
background: "#fbf8f1", ink: "#0c1e18", accent: "#e6ff55", ghostColor: "#c9d3c4"
}, opts, partial || {});
opts.speed = Math.max(0.05, num(opts.speed, 1)); opts.parallax = Math.max(0, num(opts.parallax, 1)); opts.fan = Math.max(0, num(opts.fan, 1));
var sig = opts.headline + "\u0001" + opts.ghost;
if (sig !== textSig) { textSig = sig; buildText(); }
root.querySelector(".ef-tcf__pt").textContent = opts.button;
root.querySelector(".ef-tcf__note").textContent = opts.note;
root.querySelector(".ef-tcf__ct").textContent = opts.cta;
box.style.setProperty("--ef-tcf-bg", opts.background);
box.style.setProperty("--ef-tcf-ink", opts.ink);
box.style.setProperty("--ef-tcf-acc", opts.accent);
box.style.setProperty("--ef-tcf-ghost", opts.ghostColor);
render();
}
update(o);
onScroll();
if (!("IntersectionObserver" in window)) { visible = true; start(); }
else { io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; if (visible) start(); }); io.observe(box); }
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { onScroll(); if (reduce || !started) render(); }); ro.observe(box); }
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
if (io) io.disconnect(); if (ro) ro.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"headline":"Small team,","ghost":"big results","button":"Meet the crew","note":"Eight good dogs. One tidy library. Zero filler.","cta":"Start building","speed":1,"parallax":1,"fan":1,"background":"#fbf8f1","ink":"#0c1e18","accent":"#e6ff55","ghostColor":"#c9d3c4"});How it works
One clock drives an intro timeline that starts when the hero first comes into view. The nav fades down, each headline word slides up out of a clipping mask, and the letters of the big faded line spring up one by one on GSAP's back.out(1.6) curve. The eight cards fall from 800px above, turning 25 degrees back to their own tilt and growing from 70% on back.out(1.4), staggered out from the middle the way GSAP's stagger from center works. From 1.8 seconds each card floats 8 to 18px and rocks 1.5 degrees on its own sine in-out loop of 3 to 4.5 seconds; it starts from wherever the landing card is at that moment, so a few cards keep a small offset, as in the original. The pointer adds a smoothed drift at each card's own depth through the CSS translate property, hovering a card tilts it in 3D and grows it to 112%, leaving springs it back on an elastic ease, and a click squashes it. Scrolling the hero off the top of the window fans the cards out, lifts and fades the headline and grows the faded line.
Accessibility and performance
With prefers-reduced-motion the finished layout shows straight away: no intro, no float, no pointer drift, no tilt and no scroll fan-out. The headline is a real heading whose accessible name is the whole line, while the animated word and letter pieces are hidden from screen readers. The photo cards are decorative and hidden from assistive technology. The nav links and both buttons are real links and buttons with visible focus rings.
Everything that moves is transform, opacity or translate, so the browser composites it without layout. One requestAnimationFrame loop runs the intro, the float and the pointer smoothing, and it stops while the hero is off screen or the tab is hidden. The scroll listener is passive and only reads one rectangle. The grain is a single static SVG noise tile, and the photos load lazily.
| Section | Hero |
|---|---|
| Techniques | Stagger, Text animation, Pointer interaction, Parallax and depth, CSS 3D, Scroll-driven |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Landing page: GSAP Carousel / gallery by Enrichr on CodePen. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Where are the photos from?
Photos: Alvan Nee and fatty corgi on Unsplash, used under the Unsplash License. Swap the img src values in the markup for your own team or product shots; the cards crop with object-fit: cover.
Does it need GSAP or ScrollTrigger?
No. The timeline, the back, sine and elastic eases, the stagger from the centre and the scroll progress are a few lines of plain JavaScript that copy GSAP's own maths.
Why do one or two cards sit a little lower?
The float loop on each card starts at a fixed time, and some cards are still overshooting their landing at that moment. The float keeps that offset, which gives the row its uneven, hand-placed look. The original pen behaves the same way.
Can I turn off the scroll effect?
Yes. Set the scroll fan-out strength to 0. The pointer drift has its own strength setting too.