GSAP split-text hover: each letter rolls up and is replaced by its twin, letter by letter, and always finishes.
>_ Prompt
Build a reusable link component with a letter-by-letter "roll" hover effect using GSAP.
Stack: Next.js (App Router), React, TypeScript and Tailwind CSS v4. Install gsap.
COMPONENT: RollLink({ href, className, children: string }) — a client component wrapping next/link.
MARKUP
- Put aria-label={children} on the link. Inside, an aria-hidden flex span.
- Split children into characters, turning spaces into non-breaking spaces. For each character:
- Outer span: relative, inline-block, overflow hidden, line-height 1.2.
- Inner span with data-char, display block, containing the character.
- A second copy of the character, absolutely positioned at top: 100%, left 0, directly underneath.
ANIMATION (onMouseEnter only)
- gsap.to(all [data-char] in this link, { yPercent: -100, duration: 0.22, ease: "power2.inOut", stagger: 0.015 }).
- Each letter slides up out of view while its twin slides into its place, left to right.
- onComplete: gsap.set(chars, { yPercent: 0 }) to reset instantly, which looks identical.
RULES
- Keep a "playing" ref. Ignore new mouseenter events while playing, and don't reverse on mouseleave, so the roll always finishes even if the pointer leaves early.
- No color, background or opacity change on hover — the motion is the only feedback.
- The link's own className controls layout, so it works for plain nav text and for centered pill buttons (e.g. flex, 40px tall, fully rounded, centered).
Rounded Figma-style arrow plus a lagging ring of rotating text, blended with difference mode.
>_ Prompt
Build a custom cursor: a rounded Figma-style arrow plus a ring of rotating text that follows with a lag.
Stack: Next.js (App Router), React, TypeScript and Tailwind CSS v4. Use gsap.
SETUP
- A client component mounted once in the root layout. Only run when matchMedia("(pointer: fine)") matches.
- Global CSS:
- Inside @media (pointer: fine), set cursor: none !important on *, *::before and *::after.
- Inside @media (pointer: coarse), hide the component.
- Wrapper: aria-hidden, pointer-events none, position fixed, inset 0, z-index 9999, mix-blend-mode: difference. Everything inside is pure white (#fff), so it inverts whatever is underneath.
- IMPORTANT: give <html> its own background color (the same as body). Otherwise the body background moves to the canvas, the difference blend has nothing to blend with, and the cursor shows plain white.
ARROW
- A 28×28 SVG with path "M6 5v17.5l5.2-5.2h7.8Z", drawn twice for rounded corners:
- First with a 4px stroke, round line joins and paint-order stroke.
- Then with a 1.2px stroke and round joins.
- Translate it by (-6px, -5px) so the tip sits exactly on the pointer.
- Follows the pointer with gsap.quickTo on x/y, duration 0.05.
RING
- A 124×124 SVG with a thin circle (r 38, 0.75px stroke).
- Text on a circular path of r 48: "WELCOME • SCROLL DOWN •".
- 8.5px, uppercase, letter-spacing 0.5.
- Use textLength 301 (≈ 2π×48) with lengthAdjust="spacing" so the text fills the circle evenly.
- Spins with a CSS keyframe rotate(360deg), 10s linear infinite.
- Centered with xPercent/yPercent -50. Follows with gsap.quickTo, duration 0.45, ease "power3.out", offset (+4px, +8px) so it centers on the arrow's body.
BEHAVIOR
- On the first pointermove, fade both to opacity 1 (0.2s). On pointerleave of the document, fade both out.
- When the pointer is over any a or button (check with closest), shrink the ring's inner wrapper to scale 0.5 and opacity 0 (0.25s, power2.out).
- When it leaves, bring the ring back after a 0.5s delay. Use overwrite: true so hovering another link cancels a pending reveal.
A glowing yellow band of light that sweeps left to right through the letters — not a static glow.
>_ Prompt
Create a pure-CSS "shine sweep" text effect. A glowing yellow band of light travels left to right through the letters on a loop, with a soft glow that moves with it. It is not a static outline glow.
USAGE
<span class="shine" data-text="yoursite.com">yoursite.com</span>
(data-text must match the text exactly.)
.shine
- --shine-base: the normal text color (default #1e1e1e; override per use).
- --shine-band: transparent 38%, #e0a400 44%, #ffd000 48%, #fff3a0 50%, #ffd000 52%, #e0a400 56%, transparent 62%.
- position relative; display inline-block; isolation isolate; color transparent.
- Two background layers:
- linear-gradient(100deg, var(--shine-band))
- linear-gradient(var(--shine-base), var(--shine-base))
- background-size: 300% 100%, 100% 100%.
- -webkit-background-clip: text; background-clip: text.
- animation: shine-sweep 2.6s linear infinite.
.shine::before (the moving glow)
- content: attr(data-text); position absolute; inset 0; z-index -1; color transparent.
- Background: only the band gradient, at background-size 300% 100%, clipped to text.
- filter: blur(6px); the same animation; pointer-events none.
@keyframes shine-sweep
- From background-position 100% 0, 0 0. To 0% 0, 0 0.
- Moving the oversized gradient from 100% to 0% carries the band across the text from left to right, with a natural pause between passes.
ACCESSIBILITY
- @media (prefers-reduced-motion: reduce): turn the animation off for .shine and .shine::before.
NOTE
- Don't combine it with per-letter transform animations on the same text: background-clip: text won't paint through transformed child elements.
A rounded, inset section that grows to the screen edges (and upward) as you scroll, then eases back partway as it leaves.
>_ Prompt
Build a section that grows to touch the screen edges as you scroll, using GSAP ScrollTrigger with scrub.
Stack: Next.js (App Router), React, TypeScript and Tailwind CSS v4. Use gsap and gsap/ScrollTrigger (call gsap.registerPlugin(ScrollTrigger)).
COMPONENT: GrowSection({ className, children }) — a client component.
- Markup: <section ref={outer}> wrapping <div ref={inner} className={className}>{children}</div>.
- The section's starting CSS matches the animation's start state, so nothing jumps before hydration:
- padding-left/right 80px and padding-top 80px from 768px; 32px each on phones.
- The inner div has a 64px radius from 768px (40px on phones) and its own padding (16px; 12px on phones).
ANIMATION — set up with gsap.matchMedia()
- Run one setup for "(min-width: 768px) and (prefers-reduced-motion: no-preference)" with gutter 80 and radius 64.
- Run another for "(max-width: 767px) and (prefers-reduced-motion: no-preference)" with gutter 32 and radius 40.
- Reduced motion gets no animation. Call mm.revert() on unmount.
- Read the inner div's computed padding-top once (padTop).
1) GROW IN — a timeline with scrollTrigger { trigger: section, start: "top 85%", end: "top 15%", scrub: 0.6 }:
- Section: paddingLeft/Right from gutter to 0.
- Inner: borderRadius from radius to 0. marginTop from 0 to -gutter. paddingTop from padTop to padTop + gutter.
- Use ease "none" and position 0 for both tweens.
- The negative margin pulls the top edge up while the matching extra padding keeps the content in place. Total height doesn't change, so the page below never shifts.
2) EASE BACK PARTWAY — a timeline with scrollTrigger { trigger: section, start: "bottom 85%", end: "bottom 15%", scrub: 0.6 }:
- With SHRINK = 0.4: section paddingLeft/Right from 0 to gutter × SHRINK, and inner borderRadius from 0 to radius × SHRINK.
- Use ease "none" and immediateRender: false on both, so they don't overwrite the grow state on load.
- The top is left alone, since it's already off-screen.
Everything inside the section (cards, grids) widens with it, because the layout really grows — it's not scaled.
An endless, seamless row of tech pills that fades out at both edges — pure CSS, no JavaScript.
>_ Prompt
Build an infinite, seamless "built with" marquee of tech pills. Use pure CSS, no JavaScript.
Stack: Next.js (App Router), React, TypeScript and Tailwind CSS v4.
DESIGN SYSTEM (use exactly):
- Page background #edeef2. Surface (light cards, light pills) #f6f6f8. Muted (gray cards) #d9d9dc. Border #dcdde2.
- Ink #1e1e1e for text, dark cards and primary buttons. Muted text #8a8a8f. Text on dark: #ffffff.
- Accents: purple #c084fc, lime #a3f207.
- Font: Geist Sans. Headings semibold with tight tracking (-0.04em) and tight leading (0.95–1.05).
- Cards: 28px corner radius. Buttons and chips: fully rounded pills (9999px).
- When a card wraps pills, its radius = pill radius + card padding (e.g. 40px pills + 8px padding → 28px card).
- Side gutter: 30px from 768px up, 20px on phones.
- Links and buttons never change color, background or opacity on hover.
ITEMS
- Next.js, React, TypeScript, Tailwind CSS, GSAP, ScrollTrigger, Vercel, Geist, Server Actions, Node.js.
MARKUP
- Outer: a div with aria-label "Built with", width 100%, max-width 768px, overflow hidden.
- Fade both edges with mask-image: linear-gradient(90deg, transparent, #000 15%, #000 85%, transparent).
- Inside: a track — display flex, width max-content, class "marquee-track".
- The track holds TWO identical <ul> lists. The second has aria-hidden="true" so screen readers only read the list once.
- Each <ul>: flex, shrink-0, items centered, gap 8px, and padding-right 8px.
- The padding makes the gap between the two copies equal to the gap between pills, so the loop has no visible seam.
- Each <li>: 36px tall, padding 0 16px, fully rounded, 1px #dcdde2 border, 14px medium, white-space nowrap.
ANIMATION
- .marquee-track { animation: marquee 30s linear infinite; }
- @keyframes marquee { to { transform: translateX(-50%); } }
- The track is exactly two copies wide, so moving it by -50% lands copy 2 exactly where copy 1 started, and the loop restarts invisibly.
- @media (prefers-reduced-motion: reduce): set the animation to none.
PLACEMENT
- Centered under the hero heading, inside a flex column with a 20px gap.
A heart that pops, rings and bursts into tiny lime and purple dots and hearts when you favorite something.
>_ Prompt
Build a favorite (heart) button with a playful burst animation: when it turns ON, the heart pops, a lime ring pulses out, and 10 tiny lime and purple pieces fly outward and fade. Only the button and its animation; wire onToggle to your own storage.
Stack: Next.js (App Router), React, TypeScript and Tailwind CSS v4. No animation library: React state plus CSS keyframes.
COMPONENT: FavoriteButton({ title, defaultOn = false, onToggle, size = 22, className, activeClass = "text-lime" }) — a client component.
- State: on (boolean, starts at defaultOn) and burst (a number, starts at 0).
- Click: e.preventDefault() and e.stopPropagation() (so it works inside a clickable card). If it's turning ON, do burst + 1. Then flip on and call onToggle?.(nextValue).
- <button type="button">: aria-pressed={on}; aria-label "Add {title} to favorites" / "Remove {title} from favorites"; title "Add to favorites" / "Remove from favorites".
- Button classes: relative, z-index 10, flex, shrink-0, items/justify center, plus className, plus activeClass while on.
- Inside a clickable card, give it -m-2 p-2 so the tap area is bigger without moving the layout.
HEART
- SVG 24×24 viewBox at size×size, stroke currentColor, stroke-width 1.5, stroke-linejoin round. fill currentColor when on, none when off.
- Path: M12 20.5s-7.5-4.6-9.2-9.4C1.7 7.9 3.8 4.5 7.2 4.5c2 0 3.5 1.1 4.8 2.8 1.3-1.7 2.8-2.8 4.8-2.8 3.4 0 5.5 3.4 4.4 6.6-1.7 4.8-9.2 9.4-9.2 9.4Z
- Wrap the SVG in <span key={burst} className="flex"> and add the class "fav-pop" when burst > 0 and on. The changing key remounts the span, so the pop replays on every new favorite.
BURST (render only when burst > 0 and on)
- <span key={"b" + burst} className="fav-burst" aria-hidden="true">: pointer-events none, position absolute, left 50%, top 50%.
- Inside: one <span className="fav-ring" /> and 10 pieces.
- Pieces: for i = 0..9, angle = (i / 10) × 2π − π/2 (the first one points straight up). Distance = 22px for odd i, 30px for even i.
- --tx = cos(angle) × distance px, --ty = sin(angle) × distance px (rounded to 1 decimal), set as inline CSS variables.
- Color: #c084fc (purple) for odd i, #a3f207 (lime) for even i.
- Every 3rd piece (i % 3 === 0) is a 9×9 SVG of the same heart path, filled with the color. The others are 6×6 round dots (size-1.5, rounded-full) with that background.
- animation-delay: (i % 3) × 25ms.
CSS (global)
@keyframes fav-pop { 0% scale(1); 25% scale(0.75); 55% scale(1.35); 80% scale(0.95); 100% scale(1) }
.fav-pop { animation: fav-pop 0.5s cubic-bezier(0.3, 1.5, 0.5, 1); }
@keyframes fav-ring { from { transform: translate(-50%, -50%) scale(0.3); opacity: 0.9 } to { transform: translate(-50%, -50%) scale(1.9); opacity: 0 } }
.fav-ring { position: absolute; left: 0; top: 0; width: 34px; height: 34px; border-radius: 9999px; border: 2px solid #a3f207; animation: fav-ring 0.5s ease-out forwards; }
@keyframes fav-piece {
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 1 }
60% { opacity: 1 }
100% { transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(1); opacity: 0 }
}
.fav-piece { position: absolute; left: 0; top: 0; opacity: 0; animation: fav-piece 0.65s cubic-bezier(0.2, 0.7, 0.3, 1) forwards; }
REDUCED MOTION
- @media (prefers-reduced-motion: reduce): no animation on .fav-pop, .fav-ring and .fav-piece; hide the ring and pieces; keep the heart visible (display flex).
COLORS
- Off: the heart is an outline in the surrounding text color. On: filled with activeClass (lime #a3f207 on dark cards). On a purple header, use a white 56px round button with a purple fill when on.
- The color changes only when the state changes, never on hover.