diff --git a/__tests__/metadata.test.ts b/__tests__/metadata.test.ts index 20c9557..f9323ec 100644 --- a/__tests__/metadata.test.ts +++ b/__tests__/metadata.test.ts @@ -26,6 +26,7 @@ describe("Component Metadata", () => { "navbar-sections", "unicorn-section", "interactive-illustrations", + "animations", "cards", "buttons", ] diff --git a/app/components/page.tsx b/app/components/page.tsx index 05fac83..257d9d7 100644 --- a/app/components/page.tsx +++ b/app/components/page.tsx @@ -26,6 +26,7 @@ const CATEGORY_ORDER = [ "grid-sections", "navbar-sections", "interactive-illustrations", + "animations", ] export default function ComponentsPage() { diff --git a/app/playground/animation/page.tsx b/app/playground/animation/page.tsx new file mode 100644 index 0000000..8b150d0 --- /dev/null +++ b/app/playground/animation/page.tsx @@ -0,0 +1,68 @@ +import CardsAnimation from "@/content/registry/animations/cards-animation/demo" +import Scroll1Animation from "@/content/registry/animations/scroll1-animation/demo" +import Scroll2Animation from "@/content/registry/animations/scroll2-animation/demo" +import Scroll3Animation from "@/content/registry/animations/scroll3-animation/demo" +import Scroll4Animation from "@/content/registry/animations/scroll4-animation/demo" +import Scroll5Animation from "@/content/registry/animations/scroll5-animation/demo" +import Scroll6Animation from "@/content/registry/animations/scroll6-animation/demo" +import { PlaygroundDemo } from "@/components/showcase/playground-demo" + +/** Slugs tested here before they are added to the registry. */ +const LOCAL_PLAYGROUND_SLUGS = { + "cards-animation": CardsAnimation, + "scroll1-animation": Scroll1Animation, + "scroll2-animation": Scroll2Animation, + "scroll3-animation": Scroll3Animation, + "scroll4-animation": Scroll4Animation, + "scroll5-animation": Scroll5Animation, + "scroll6-animation": Scroll6Animation, +} as const + +const LOCAL_PLAYGROUND_BG: Record = { + "cards-animation": "#ffffff", + "scroll1-animation": "#000000", + "scroll2-animation": "#000000", + "scroll3-animation": "#000000", + "scroll4-animation": "#000000", + "scroll5-animation": "#000000", + "scroll6-animation": "#000000", +} + +type LocalPlaygroundSlug = keyof typeof LOCAL_PLAYGROUND_SLUGS + +function isLocalPlaygroundSlug(slug: string): slug is LocalPlaygroundSlug { + return slug in LOCAL_PLAYGROUND_SLUGS +} + +/** Change this slug or use `?slug=your-animation` in the URL to test another component. */ +const DEFAULT_PLAYGROUND_SLUG = "cards-animation" + +interface AnimationPlaygroundPageProps { + searchParams: Promise<{ slug?: string }> +} + +export default async function AnimationPlaygroundPage({ + searchParams, +}: AnimationPlaygroundPageProps) { + const { slug } = await searchParams + const demoSlug = slug ?? DEFAULT_PLAYGROUND_SLUG + + if (isLocalPlaygroundSlug(demoSlug)) { + const LocalDemo = LOCAL_PLAYGROUND_SLUGS[demoSlug] + + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/app/playground/grid/page.tsx b/app/playground/grid/page.tsx index d5baccc..5f681d6 100644 --- a/app/playground/grid/page.tsx +++ b/app/playground/grid/page.tsx @@ -10,6 +10,7 @@ import Featured9Grid from "@/content/registry/grid-sections/featured9-grid/demo" import Featured10Grid from "@/content/registry/grid-sections/featured10-grid/demo" import CompanionGrid from "@/content/registry/grid-sections/companion-grid/demo" import Featured11Grid from "@/content/registry/grid-sections/featured11-grid/demo" +import PaperGrid from "@/content/registry/grid-sections/paper-grid/demo" import { PlaygroundDemo } from "@/components/showcase/playground-demo" /** Slugs tested here before they are added to the registry. */ @@ -26,6 +27,7 @@ const LOCAL_PLAYGROUND_SLUGS = { "featured10-grid": Featured10Grid, "companion-grid": CompanionGrid, "featured11-grid": Featured11Grid, + "paper-grid": PaperGrid, } as const const LOCAL_PLAYGROUND_BG: Record = { @@ -41,6 +43,7 @@ const LOCAL_PLAYGROUND_BG: Record = "featured10-grid": "#fbfbfc", "companion-grid": "#fffaf4", "featured11-grid": "#ffffff", + "paper-grid": "#08080a", } type LocalPlaygroundSlug = keyof typeof LOCAL_PLAYGROUND_SLUGS diff --git a/content/featured.ts b/content/featured.ts index 327153b..7d36339 100644 --- a/content/featured.ts +++ b/content/featured.ts @@ -6,6 +6,7 @@ * or a removed component never breaks the page. */ export const featuredSlugs: string[] = [ + "paper-grid", "selfie-hero", "featured2-grid", "air-hero", @@ -17,6 +18,9 @@ export const featuredSlugs: string[] = [ "featured6-grid", "lena-hero", "paper-hero", + "cards-animation", + "scroll1-animation", + "scroll3-animation", "dove-unicorn", "amind-hero", "pop-hero", diff --git a/content/registry/animations/cards-animation/demo.tsx b/content/registry/animations/cards-animation/demo.tsx new file mode 100644 index 0000000..b175286 --- /dev/null +++ b/content/registry/animations/cards-animation/demo.tsx @@ -0,0 +1,654 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import gsap from "gsap" + +/* ───────────────────────────────────────────── + cards-animation — the deal-in carousel wheel + + Eighteen cards fly in from alternating sides, + spinning and scaling down from four times their + size, then settle onto a giant wheel whose pivot + sits 200vh below the stage — so only the top arc + is ever on screen and the cards fan out like a + hand being dealt. + + Scrolling turns the wheel. The component owns its + own scroll container (a tall track under a sticky + stage), so it reads its own scrollTop and never + touches window scroll or position:fixed. + + Most cards are monochrome; a few are duotoned warm + or cool so the wheel reads as one object with two + accents rather than eighteen competing photos. + + Click a card and it lifts out of the wheel to the + centre, drops every filter, and opens its detail + panel. Escape or the backdrop sends it back. + + Deal-in choreography after a GSAP study by + @deadrabbbbit, itself inspired by the Hyundai site. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781877270/zepalogo1_bnzxuc.png" + +/** tint: which cards break the monochrome — 2 warm, 2 cool per set of nine */ +type Tint = "mono" | "warm" | "cool" + +type Card = { + src: string + title: string + kind: string + body: string + tint: Tint +} + +const BASE: Card[] = [ + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + title: "Selfie Hero", + kind: "Hero section", + body: "A hero that greets the visitor with their own reflection — camera-lit, permission-gated, and gracefully blank when the answer is no.", + tint: "mono", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + title: "Paper Grid", + kind: "Grid section", + body: "Three certificates printed on glass, bending like real paper. The curl is integrated along the sheet, so the silhouette pulls in where the page turns away.", + tint: "warm", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + title: "Zaveda Interface", + kind: "Hero section", + body: "A savings-app hero on soft lilac, with three phones fanned and a live earnings readout built from real digit wheels.", + tint: "mono", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + title: "Companion Grid", + kind: "Grid section", + body: "A train of polaroids clears away one at a time as you scroll, revealing the line underneath. Starts on the last card and ends on the first.", + tint: "cool", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + title: "Scrolldown Hero", + kind: "Hero section", + body: "Trading cards float up on buttery easing while the background marquee drifts sideways and speeds up with your scroll velocity.", + tint: "mono", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + title: "Wave Hero", + kind: "Hero section", + body: "A displacement field runs across the type so the headline reads as something seen through moving water rather than sitting on top of it.", + tint: "warm", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + title: "Osmos Hero", + kind: "Hero section", + body: "Soft bodies drift, meet and merge under a metaball threshold — no physics engine, just a distance field resolved per pixel.", + tint: "mono", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + title: "Drops Hero", + kind: "Hero section", + body: "An image trail that follows the cursor and thins out as it goes, each drop inheriting a little less of the last one's momentum.", + tint: "cool", + }, + { + src: "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", + title: "Bluish Hero", + kind: "Hero section", + body: "A cold-lit landing surface where the gradient is doing the typography's job, and the copy simply stays out of its way.", + tint: "mono", + }, +] + +/** served at the card's own aspect so the wheel is not fetching nine originals */ +const fit = (u: string) => + u.replace("/upload/", "/upload/c_fill,w_520,h_740,g_auto/") + +const CARDS: Card[] = [...BASE, ...BASE].map((c) => ({ ...c, src: fit(c.src) })) + +/** how far the wheel turns across the full scroll track */ +const SPIN_DEGREES = 360 + +export default function CardsAnimation() { + const rootRef = useRef(null) + const itemsRef = useRef(null) + const [open, setOpen] = useState(null) + /* the panel has to stay mounted while it animates out, so closing is a + state of its own rather than just clearing `open` */ + const [closing, setClosing] = useState(false) + const closeTimer = useRef(null) + + const CLOSE_MS = 300 + + const openCard = useCallback((i: number) => { + if (closeTimer.current !== null) { + clearTimeout(closeTimer.current) + closeTimer.current = null + } + setClosing(false) + setOpen(i) + }, []) + + const close = useCallback(() => { + if (closeTimer.current !== null) return // already on the way out + setClosing(true) + closeTimer.current = window.setTimeout(() => { + setOpen(null) + setClosing(false) + closeTimer.current = null + }, CLOSE_MS) + }, []) + + // never leave a timer running past unmount + useEffect( + () => () => { + if (closeTimer.current !== null) clearTimeout(closeTimer.current) + }, + [] + ) + + /* ── entrance: the deal-in ─────────────────────────────────────────── */ + useEffect(() => { + const root = rootRef.current + const itemsEl = itemsRef.current + if (!root || !itemsEl) return + + /* context scopes every selector below to this root and gives us one + revert() that undoes each tween and inline style on unmount */ + const ctx = gsap.context(() => { + const images = gsap.utils.toArray(".ca-item") + const imageSize = images.length + const degree = 360 / imageSize + + gsap.set(itemsEl, { transformOrigin: "center 200vh" }) + + const timeline = gsap.timeline() + + images.forEach((image, index) => { + const sign = Math.floor((index / 2) % 2) ? 1 : -1 + const value = Math.floor((index + 4) / 4) * 4 + const rotation = index > imageSize - 3 ? 0 : sign * value + + gsap.set(image, { rotation, scale: 0.5 }) + + timeline.from( + image, + { + /* the original threw these off the window's edges; the stage's + own box is what a registry component may measure */ + x: () => + index % 2 + ? root.clientWidth + image.clientWidth * 4 + : -root.clientWidth - image.clientWidth * 4, + y: () => root.clientHeight - image.clientHeight, + rotation: index % 2 ? 200 : -200, + scale: 4, + opacity: 1, + ease: "power4.out", + duration: 1, + delay: 0.15 * Math.floor(index / 2), + }, + 0 + ) + + timeline.to(image, { scale: 1, duration: 0 }, 0.15 * (imageSize / 2 - 1) + 1) + + timeline.to( + image, + { + transformOrigin: "center 200vh", + rotation: + index > imageSize / 2 ? -degree * (imageSize - index) : index * degree, + duration: 1, + ease: "power1.out", + }, + 0.15 * (imageSize / 2 - 1) + 1 + ) + }) + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + timeline.progress(1).pause() + } + }, root) + + return () => ctx.revert() + }, []) + + /* ── scroll turns the wheel ────────────────────────────────────────── */ + useEffect(() => { + const root = rootRef.current + const itemsEl = itemsRef.current + if (!root || !itemsEl) return + + let raf = 0 + let cur = root.scrollTop + + const tick = () => { + raf = requestAnimationFrame(tick) + + /* the component's own scroll position, never the window's — a + registry component can be embedded anywhere and must not + assume it owns the page */ + const target = root.scrollTop + cur += (target - cur) * 0.09 // lenis-style ease + if (Math.abs(target - cur) < 0.05) cur = target + + const travel = root.scrollHeight - root.clientHeight + const p = travel > 0 ? Math.min(1, Math.max(0, cur / travel)) : 0 + /* driven through gsap rather than the CSS rotate property, so the + wheel shares one transform (and one transform-origin) with the + cards instead of composing rotate and transform separately */ + gsap.set(itemsEl, { rotation: -p * SPIN_DEGREES }) + } + + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, []) + + /* ── Escape closes the detail panel ────────────────────────────────── */ + useEffect(() => { + if (open === null) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close() + } + document.addEventListener("keydown", onKey) + return () => document.removeEventListener("keydown", onKey) + }, [open, close]) + + const active = open === null ? null : CARDS[open] + + return ( +
+ + +
+
+
+ Zepa +
+ +
+
+
+ {CARDS.map((card, i) => ( +
+
openCard(i)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + openCard(i) + } + }} + > + + {card.title} +
+
+ ))} +
+
+
+ +

Scroll to turn · Click a card to open

+ + {/* ── detail panel ── */} + {active && ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={active.title} + > +
+ +
+ +
+ {active.kind} +

{active.title}

+

{active.body}

+ +
+
+
+ )} +
+
+
+ ) +} + +const CSS = ` +.ca-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #ffffff; + color: #000000; + font-weight: 600; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + scrollbar-width: none; +} +.ca-root::-webkit-scrollbar { display: none; } +.ca-root--locked { overflow: hidden; } + +.ca-root *, +.ca-root *::before, +.ca-root *::after { box-sizing: border-box; } + +/* the scroll runway — its length sets how far one turn takes */ +.ca-track { position: relative; width: 100%; height: 400vh; } + +.ca-stage { + position: sticky; + top: 0; + height: 100vh; + overflow: hidden; + background-color: #ffffff; +} + +/* the logo reads as the section's title, so it is sized like one — + capped in vh as well as vw so it cannot crowd the wheel, which + starts at 28% of the stage */ +.ca-logo { + position: absolute; + z-index: 20; + top: clamp(20px, 4vh, 52px); + left: 50%; + transform: translateX(-50%); + pointer-events: none; +} +.ca-logo img { + display: block; + height: min(clamp(52px, 7.4vw, 132px), 15vh); + width: auto; + user-select: none; + -webkit-user-select: none; +} + +.ca-container { + position: absolute; + inset: 0; + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +.ca-center { + height: 100vh; + position: absolute; + left: 50%; + top: 28%; + transform: translate(-50%); + user-select: none; + -webkit-user-select: none; +} + +.ca-items { + transform-origin: center 200vh; + user-select: none; + -webkit-user-select: none; + display: flex; + will-change: transform; +} + +.ca-item { + position: absolute; + user-select: none; + -webkit-user-select: none; + transform: translateX(-50%); +} + +.ca-card { + display: block; + width: 430px; + height: 610px; + color: #fff; + user-select: none; + -webkit-user-select: none; + border-radius: 17px; + overflow: hidden; + cursor: pointer; + position: relative; + border: 4px solid #000000; + transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); +} +.ca-card:active { transform: scale(0.97); } +.ca-card:focus-visible { outline: 3px solid #ff6a1f; outline-offset: 4px; } + +.ca-image { + height: 100%; + width: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + pointer-events: none; + transition: filter 0.4s ease; +} + +/* duotone via filters, so the accent is the same regardless of the photo */ +.ca-card--mono .ca-image { filter: grayscale(1) contrast(1.05); } +.ca-card--warm .ca-image { + filter: grayscale(1) sepia(1) saturate(4.2) hue-rotate(-12deg) contrast(1.02); +} +.ca-card--cool .ca-image { + filter: grayscale(1) sepia(1) saturate(4.6) hue-rotate(178deg) contrast(1.02); +} +.ca-card:hover .ca-image { filter: none; } + +.ca-card-tag { + position: absolute; + left: 14px; + bottom: 14px; + z-index: 2; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #fff; + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.6); + opacity: 0; + transition: opacity 0.3s ease; +} +.ca-card:hover .ca-card-tag { opacity: 1; } + +.ca-hint { + position: absolute; + left: 0; + right: 0; + bottom: 26px; + z-index: 20; + margin: 0; + text-align: center; + pointer-events: none; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.42); +} + +/* ── detail panel ── */ +.ca-detail { + position: absolute; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + animation: caFade 0.3s ease both; +} +@keyframes caFade { from { opacity: 0; } to { opacity: 1; } } +@keyframes caFadeOut { from { opacity: 1; } to { opacity: 0; } } + +.ca-detail-inner { + display: flex; + align-items: center; + gap: 44px; + max-width: 940px; + width: 100%; + animation: caRise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes caRise { + from { opacity: 0; transform: translateY(30px) scale(0.94); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes caSink { + from { opacity: 1; transform: translateY(0) scale(1); } + to { opacity: 0; transform: translateY(18px) scale(0.96); } +} + +/* the artwork leads on the way in and trails on the way out, so the panel + reads as the card growing rather than a box appearing over it */ +.ca-detail-card { + flex: 0 0 auto; + width: 320px; + height: 454px; + border-radius: 17px; + overflow: hidden; + border: 5px solid #000000; + box-shadow: 0 40px 90px -30px rgba(0, 0, 0, 0.5); + animation: caCardIn 0.52s cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes caCardIn { + from { opacity: 0; transform: scale(0.82) rotate(-4deg); } + to { opacity: 1; transform: scale(1) rotate(0deg); } +} +@keyframes caCardOut { + from { opacity: 1; transform: scale(1) rotate(0deg); } + to { opacity: 0; transform: scale(0.88) rotate(3deg); } +} +.ca-detail-card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* the copy comes in just behind the artwork */ +.ca-detail-kind, +.ca-detail-copy h2, +.ca-detail-copy p, +.ca-detail-close { + animation: caCopyIn 0.44s cubic-bezier(0.22, 1, 0.36, 1) both; +} +.ca-detail-kind { animation-delay: 0.08s; } +.ca-detail-copy h2 { animation-delay: 0.13s; } +.ca-detail-copy p { animation-delay: 0.18s; } +.ca-detail-close { animation-delay: 0.23s; } +@keyframes caCopyIn { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── on the way out ── */ +.ca-detail--closing { animation: caFadeOut 0.3s ease both; } +.ca-detail--closing .ca-detail-inner { animation: caSink 0.3s cubic-bezier(0.4, 0, 1, 1) both; } +.ca-detail--closing .ca-detail-card { animation: caCardOut 0.3s cubic-bezier(0.4, 0, 1, 1) both; } +.ca-detail--closing .ca-detail-kind, +.ca-detail--closing .ca-detail-copy h2, +.ca-detail--closing .ca-detail-copy p, +.ca-detail--closing .ca-detail-close { + animation: caFadeOut 0.18s ease both; + animation-delay: 0s; +} + +.ca-detail-copy { flex: 1 1 auto; min-width: 0; } +.ca-detail-kind { + display: inline-block; + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.45); + margin-bottom: 14px; +} +.ca-detail-copy h2 { + margin: 0 0 14px; + font-size: clamp(30px, 3.6vw, 52px); + line-height: 1.03; + letter-spacing: -0.035em; + font-weight: 700; +} +.ca-detail-copy p { + margin: 0 0 26px; + font-size: 16px; + line-height: 1.6; + font-weight: 500; + color: rgba(0, 0, 0, 0.66); + max-width: 46ch; +} +.ca-detail-close { + font: inherit; + font-size: 13px; + font-weight: 600; + color: #fff; + background: #000000; + border: none; + border-radius: 999px; + padding: 12px 26px; + cursor: pointer; + transition: opacity 0.2s ease; +} +.ca-detail-close:hover { opacity: 0.82; } + +@media screen and (max-height: 1000px) { + .ca-card { width: 350px; height: 497px; } +} +@media screen and (max-height: 800px) { + .ca-card { width: 300px; height: 400px; } +} + +@media (max-width: 820px) { + .ca-detail-inner { flex-direction: column; gap: 24px; text-align: center; } + .ca-detail-card { width: 210px; height: 298px; border-width: 4px; } + .ca-detail-copy p { margin-inline: auto; } +} + +@media (prefers-reduced-motion: reduce) { + .ca-detail, + .ca-detail-inner, + .ca-detail-card, + .ca-detail-kind, + .ca-detail-copy h2, + .ca-detail-copy p, + .ca-detail-close, + .ca-detail--closing, + .ca-detail--closing .ca-detail-inner, + .ca-detail--closing .ca-detail-card { animation: none; } + .ca-card, .ca-image, .ca-card-tag { transition: none; } + .ca-card:active { transform: none; } +} +` diff --git a/content/registry/animations/cards-animation/meta.ts b/content/registry/animations/cards-animation/meta.ts new file mode 100644 index 0000000..6207786 --- /dev/null +++ b/content/registry/animations/cards-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "cards-animation", + title: "Cards Animation", + description: + "Eighteen cards fly in from alternating sides and settle onto a giant wheel that scrolling turns. Mostly duotone monochrome with warm and cool accents; click a card to open its detail panel. Inspired by @deadrabbbbit.", + category: "animations", + preview: "/previews/animations/cards-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scroll", + "carousel", + "wheel", + "cards", + "duotone", + "lightbox", + "light", + "interactive", + ], + dependencies: ["gsap"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll1-animation/demo.tsx b/content/registry/animations/scroll1-animation/demo.tsx new file mode 100644 index 0000000..5e13528 --- /dev/null +++ b/content/registry/animations/scroll1-animation/demo.tsx @@ -0,0 +1,298 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll1-animation — Effortless motion, Zepa's quiet craft. + + A wall of images rotated 25° on the Y axis, each tile parked at a random depth between −1600 and 200px, sweeping right across the viewport as you scroll while the inner picture scales from 2 down to 0.5 — so the frame travels but the photograph inside it settles. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 48 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 48 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +export default function Scroll1Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s1-item") + const inners = items.map((el) => el.querySelector(".s1-inner") as HTMLElement) + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + tl.set(wrap, { rotationY: 25 }) + .set(items, { z: () => gsap.utils.random(-1600, 200) }) + .fromTo(items, + { xPercent: () => gsap.utils.random(-1000, -500) }, + { xPercent: () => gsap.utils.random(500, 1000) }, 0) + .fromTo(inners, { scale: 2 }, { scale: 0.5 }, 0) + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 01

+

Scroll down

+
+ +
+

+ Effortless motion, +
+ Zepa’s quiet craft. +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s1-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --perspective: 1000px; + --grid-inner-scale: 0.5; +} +.s1-root::-webkit-scrollbar { display: none; } +.s1-root *, +.s1-root *::before, +.s1-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway. + starts and ends off-screen left/right, so the blank head and tail are trimmed rather than scrolled + through. Raise these to see more of the timeline, lower to scroll less. */ +.s1-content { position: relative; width: 100%; padding: 0; } +.s1-intro { + height: 46vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s1-outro { height: 58vh; display: grid; place-items: center; } + +.s1-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s1-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s1-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s1-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s1-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s1ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s1ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s1-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s1-section { position: relative; } + +.s1-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s1-title--top { align-items: start; } +.s1-title--bottom { align-items: end; } +.s1-title--left { justify-items: start; text-align: left; } +.s1-title--right { justify-items: end; text-align: right; } + +.s1-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s1-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s1-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s1-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll1-animation/meta.ts b/content/registry/animations/scroll1-animation/meta.ts new file mode 100644 index 0000000..42fd35f --- /dev/null +++ b/content/registry/animations/scroll1-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll1-animation", + title: "Scroll 1 Animation", + description: + "A perspective wall of images rotated on the Y axis, each tile parked at its own depth and sweeping across the viewport as you scroll. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll1-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "perspective", + "sweep", + "depth", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll2-animation/demo.tsx b/content/registry/animations/scroll2-animation/demo.tsx new file mode 100644 index 0000000..f9dfa31 --- /dev/null +++ b/content/registry/animations/scroll2-animation/demo.tsx @@ -0,0 +1,303 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll2-animation — Every detail, already considered. + + Tiles rise out of deep space — pushed 1000 to 3000px back — turning from −45° to 45° and dimming from double brightness to black as they pass, while the whole grid counter-rotates and scales up beneath them. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 48 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 48 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +export default function Scroll2Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s2-item") + const inners = items.map((el) => el.querySelector(".s2-inner") as HTMLElement) + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + tl.set(wrap, { rotationX: 20 }) + .set(items, { z: () => gsap.utils.random(-3000, -1000) }) + .fromTo(items, + { yPercent: () => gsap.utils.random(100, 1000), rotationY: -45, filter: 'brightness(200%)' }, + { ease: 'power2', yPercent: () => gsap.utils.random(-1000, -100), rotationY: 45, filter: 'brightness(0%)' }, 0) + .fromTo(wrap, { rotationZ: -5 }, { rotationX: -20, rotationZ: 10, scale: 1.2 }, 0) + .fromTo(inners, { scale: 2 }, { scale: 0.5 }, 0) + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 02

+

Scroll down

+
+ +
+

+ Every detail, +
+ already considered. +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s2-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --grid-width: 160%; + --perspective: 2000px; + --grid-inner-scale: 0.5; + --grid-item-ratio: 0.8; + --grid-columns: 6; + --grid-gap: 14vw; +} +.s2-root::-webkit-scrollbar { display: none; } +.s2-root *, +.s2-root *::before, +.s2-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway. + starts below and far, ends above and at brightness 0, so the blank head and tail are trimmed rather than scrolled + through. Raise these to see more of the timeline, lower to scroll less. */ +.s2-content { position: relative; width: 100%; padding: 0; } +.s2-intro { + height: 46vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s2-outro { height: 58vh; display: grid; place-items: center; } + +.s2-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s2-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s2-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s2-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s2-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s2ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s2ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s2-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s2-section { position: relative; } + +.s2-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s2-title--top { align-items: start; } +.s2-title--bottom { align-items: end; } +.s2-title--left { justify-items: start; text-align: left; } +.s2-title--right { justify-items: end; text-align: right; } + +.s2-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s2-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s2-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s2-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll2-animation/meta.ts b/content/registry/animations/scroll2-animation/meta.ts new file mode 100644 index 0000000..266f754 --- /dev/null +++ b/content/registry/animations/scroll2-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll2-animation", + title: "Scroll 2 Animation", + description: + "Tiles rise out of deep space, turning and dimming as they pass while the grid counter-rotates and scales beneath them. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll2-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "perspective", + "rise", + "brightness", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll3-animation/demo.tsx b/content/registry/animations/scroll3-animation/demo.tsx new file mode 100644 index 0000000..bec6920 --- /dev/null +++ b/content/registry/animations/scroll3-animation/demo.tsx @@ -0,0 +1,308 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll3-animation — Copy it now, ship it today. + + The grid starts 5000px away, pitched back on its top edge and unlit, then flattens and brightens as the whole wrap rushes 6500px toward the camera — a flythrough where the destination assembles itself on approach. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 48 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 48 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +export default function Scroll3Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s3-item") + const inners = items.map((el) => el.querySelector(".s3-inner") as HTMLElement) + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + tl.set(items, { + transformOrigin: '50% 0%', + z: () => gsap.utils.random(-5000, -2000), + rotationX: () => gsap.utils.random(-65, -25), + filter: 'brightness(0%)', + }) + .to(items, { + xPercent: () => gsap.utils.random(-150, 150), + yPercent: () => gsap.utils.random(-300, 300), + rotationX: 0, + filter: 'brightness(200%)', + }, 0) + .to(wrap, { z: 6500 }, 0) + .fromTo(inners, { scale: 2 }, { scale: 0.5 }, 0) + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 03

+

Scroll down

+
+ +
+

+ Copy it now, +
+ ship it today. +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s3-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --grid-width: 105%; + --grid-columns: 8; + --perspective: 1500px; + --grid-inner-scale: 0.5; +} +.s3-root::-webkit-scrollbar { display: none; } +.s3-root *, +.s3-root *::before, +.s3-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway — the full lead-in and lead-out, as the original's trigger range + asks for: start fires at "top bottom+=5%" and end at "bottom top-=5%", + so anything under ~105vh either side clips the timeline. */ +.s3-content { position: relative; width: 100%; padding: 0; } +.s3-intro { + height: 90vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s3-outro { height: 110vh; display: grid; place-items: center; } + +.s3-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s3-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s3-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s3-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s3-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s3ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s3ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s3-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s3-section { position: relative; } + +.s3-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s3-title--top { align-items: start; } +.s3-title--bottom { align-items: end; } +.s3-title--left { justify-items: start; text-align: left; } +.s3-title--right { justify-items: end; text-align: right; } + +.s3-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s3-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s3-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s3-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll3-animation/meta.ts b/content/registry/animations/scroll3-animation/meta.ts new file mode 100644 index 0000000..386f4ae --- /dev/null +++ b/content/registry/animations/scroll3-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll3-animation", + title: "Scroll 3 Animation", + description: + "The grid starts far away and unlit, then flattens and brightens as it rushes toward the camera — a flythrough that assembles itself on approach. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll3-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "flythrough", + "depth", + "brightness", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll4-animation/demo.tsx b/content/registry/animations/scroll4-animation/demo.tsx new file mode 100644 index 0000000..84cabe9 --- /dev/null +++ b/content/registry/animations/scroll4-animation/demo.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll4-animation — One paste unfolds a finished scene + + A narrow column seen from its left edge. Each tile hinges on its top edge from −70° to 70° while stepping 500px toward the camera and back again on a 0.04s stagger — pages turning in sequence rather than all at once. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 24 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 24 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +export default function Scroll4Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s4-item") + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + tl.set(wrap, { transformOrigin: '0% 50%', rotationY: 30, xPercent: -75 }) + .set(items, { transformOrigin: '50% 0%' }) + .to(items, { duration: 0.5, ease: 'power2', z: 500, stagger: 0.04 }, 0) + .to(items, { duration: 0.5, ease: 'power2.in', z: 0, stagger: 0.04 }, 0.5) + .fromTo(items, + { rotationX: -70, filter: 'brightness(120%)' }, + { duration: 1, rotationX: 70, filter: 'brightness(0%)', stagger: 0.04 }, 0) + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 04

+

Scroll down

+
+ +
+

+ One paste unfolds +
+ a finished scene +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s4-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --grid-width: 50%; + --perspective: 3000px; + --grid-item-ratio: 0.8; + --grid-columns: 3; + --grid-gap: 1vw; +} +.s4-root::-webkit-scrollbar { display: none; } +.s4-root *, +.s4-root *::before, +.s4-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway — the full lead-in and lead-out, as the original's trigger range + asks for: start fires at "top bottom+=5%" and end at "bottom top-=5%", + so anything under ~105vh either side clips the timeline. */ +.s4-content { position: relative; width: 100%; padding: 0; } +.s4-intro { + height: 90vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s4-outro { height: 110vh; display: grid; place-items: center; } + +.s4-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s4-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s4-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s4-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s4-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s4ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s4ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s4-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s4-section { position: relative; } + +.s4-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s4-title--top { align-items: start; } +.s4-title--bottom { align-items: end; } +.s4-title--left { justify-items: start; text-align: left; } +.s4-title--right { justify-items: end; text-align: right; } + +.s4-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s4-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s4-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s4-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll4-animation/meta.ts b/content/registry/animations/scroll4-animation/meta.ts new file mode 100644 index 0000000..82a7ccb --- /dev/null +++ b/content/registry/animations/scroll4-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll4-animation", + title: "Scroll 4 Animation", + description: + "A narrow column seen from its left edge, each tile hinging on its top edge and stepping toward the camera on a stagger — pages turning in sequence. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll4-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "hinge", + "stagger", + "pages", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll5-animation/demo.tsx b/content/registry/animations/scroll5-animation/demo.tsx new file mode 100644 index 0000000..11f2367 --- /dev/null +++ b/content/registry/animations/scroll5-animation/demo.tsx @@ -0,0 +1,321 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll5-animation — An infinite library of components unfolding + + A gapless field laid almost flat at 50°. Even rows slide left and odd rows slide right, tearing the image apart along its own seams, then every tile drifts vertically once the rows have parted. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 48 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 48 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +/** + * Group the tiles into rows by their vertical centre, then keep every other + * row. Ported from the original's getGrid() helper, reduced to the one axis + * this animation actually uses and flattened, since GSAP receives a flat + * target list either way. + */ +function rowsOf(items: HTMLElement[], which: "even" | "odd") { + const buckets = new Map() + for (const el of items) { + const b = el.getBoundingClientRect() + const key = Math.round(b.top + b.height / 2) + const found = buckets.get(key) + if (found) found.push(el) + else buckets.set(key, [el]) + } + const ordered = [...buckets.keys()].sort((a, b) => a - b).map((k) => buckets.get(k) as HTMLElement[]) + const onlyEven = which === "even" + return ordered.filter((_, i) => !(i % 2) === onlyEven).flat() +} + +export default function Scroll5Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s5-item") + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + const grouped = { even: rowsOf(items, 'even'), odd: rowsOf(items, 'odd') } + + tl.set(wrap, { rotationX: 50 }) + .to(wrap, { rotationX: 30 }) + .fromTo(items, { filter: 'brightness(0%)' }, { filter: 'brightness(100%)' }, 0) + .to(grouped.even, { xPercent: -100, ease: 'power1' }, 0) + .to(grouped.odd, { xPercent: 100, ease: 'power1' }, 0) + .addLabel('rowsEnd', '>-=0.15') + .to(items, { ease: 'power1', yPercent: () => gsap.utils.random(-100, 200) }, 'rowsEnd') + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 05

+

Scroll down

+
+ +
+

+ An infinite library +
+ of components unfolding +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s5-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --grid-width: 120%; + --grid-columns: 8; + --grid-gap: 0; +} +.s5-root::-webkit-scrollbar { display: none; } +.s5-root *, +.s5-root *::before, +.s5-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway — the full lead-in and lead-out, as the original's trigger range + asks for: start fires at "top bottom+=5%" and end at "bottom top-=5%", + so anything under ~105vh either side clips the timeline. */ +.s5-content { position: relative; width: 100%; padding: 0; } +.s5-intro { + height: 90vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s5-outro { height: 110vh; display: grid; place-items: center; } + +.s5-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s5-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s5-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s5-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s5-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s5ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s5ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s5-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s5-section { position: relative; } + +.s5-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s5-title--top { align-items: start; } +.s5-title--bottom { align-items: end; } +.s5-title--left { justify-items: start; text-align: left; } +.s5-title--right { justify-items: end; text-align: right; } + +.s5-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s5-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s5-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s5-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll5-animation/meta.ts b/content/registry/animations/scroll5-animation/meta.ts new file mode 100644 index 0000000..e8b6888 --- /dev/null +++ b/content/registry/animations/scroll5-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll5-animation", + title: "Scroll 5 Animation", + description: + "A gapless field laid almost flat. Even rows slide left and odd rows slide right, tearing the image apart along its own seams. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll5-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "shear", + "rows", + "flat", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/animations/scroll6-animation/demo.tsx b/content/registry/animations/scroll6-animation/demo.tsx new file mode 100644 index 0000000..cce69c5 --- /dev/null +++ b/content/registry/animations/scroll6-animation/demo.tsx @@ -0,0 +1,299 @@ +"use client" + +import { useEffect, useRef } from "react" +import gsap from "gsap" +import { ScrollTrigger } from "gsap/ScrollTrigger" +import Lenis from "lenis" + +/* ───────────────────────────────────────────── + scroll6-animation — Interfaces shift, motion flows. + + Tiles hinge from a pivot two heights below themselves, swinging up from 400% down while spinning a full 360° on the Y axis, fading to a fifth opacity on a 0.03s stagger — a deck flipping itself into place. + + One of six on-scroll perspective grid animations + from Codrops' Scroll3DGrid (MIT — Codrops), split + here into a component per animation. + + The original drives off window scroll with Lenis + on the page. This owns its own scroll container + instead: Lenis is bound to the root as its wrapper + and ScrollTrigger is given that same root as its + scroller, so the component never touches window + scroll or position:fixed. + ───────────────────────────────────────────── */ + +const LOGO = + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973374/zzepa_fur8kl.png" + +const BASE = [ + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/01_wvqrxz.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/02_efyml3.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/03_sceom4.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/04_dpquqc.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/05_ccn9so.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973294/06_p0lonf.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/07_wzzq5u.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/08_bu1urh.png", + "https://res.cloudinary.com/dakrfj1oh/image/upload/v1781973295/09_b5kt8t.png", +] + +/** the grid asks for 21 tiles; the nine images repeat to fill it */ +const TILES = Array.from({ length: 21 }, (_, i) => + BASE[i % BASE.length].replace("/upload/", "/upload/c_fill,w_600,h_600,g_auto/") +) + +export default function Scroll6Animation() { + const rootRef = useRef(null) + const contentRef = useRef(null) + const wrapRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const content = contentRef.current + const wrap = wrapRef.current + if (!root || !content || !wrap) return + + gsap.registerPlugin(ScrollTrigger) + + /* Lenis smooths the component's own scroller, not the page's */ + const lenis = new Lenis({ wrapper: root, content, lerp: 0.1, smoothWheel: true }) + const onScroll = () => ScrollTrigger.update() + lenis.on("scroll", onScroll) + + let raf = 0 + const loop = (time: number) => { + lenis.raf(time) + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + + const ctx = gsap.context(() => { + const items = gsap.utils.toArray(".s6-item") + + const tl = gsap.timeline({ + defaults: { ease: "none" }, + scrollTrigger: { + trigger: wrap, + scroller: root, + start: "top bottom+=5%", + end: "bottom top-=5%", + scrub: true, + }, + }) + + tl.fromTo(items, + { transformOrigin: '50% 200%', rotationX: 0, yPercent: 400 }, + { yPercent: 0, rotationY: 360, opacity: 0.2, scale: 0.8, stagger: 0.03 }) + }, root) + + return () => { + cancelAnimationFrame(raf) + lenis.off("scroll", onScroll) + lenis.destroy() + ctx.revert() + } + }, []) + + return ( +
+ + +
+ {/* lead-in the scrub needs — carries the mark instead of nothing */} +
+ Zepa +

Zepa Motion · Scroll Animation 06

+

Scroll down

+
+ +
+

+ Interfaces shift, +
+ motion flows. +

+ +
+
+ {TILES.map((src, i) => ( +
+
+
+ ))} +
+
+
+ + {/* tail the scrub needs, ditto */} +
+ Zepa +
+
+
+ ) +} + +const CSS = ` +.s6-root { + position: relative; + width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: #000000; + color: #ffffff; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 300; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-width: none; + + /* defaults from the original :root, then this animation's overrides */ + --perspective: 1500px; + --grid-item-ratio: 1.5; + --grid-width: 100%; + --grid-height: auto; + --grid-gap: 2vw; + --grid-columns: 4; + --grid-inner-scale: 1; + --perspective: 2500px; + --grid-width: 100%; + --grid-gap: 6; + --grid-columns: 3; + --grid-item-ratio: 1; +} +.s6-root::-webkit-scrollbar { display: none; } +.s6-root *, +.s6-root *::before, +.s6-root *::after { box-sizing: border-box; } + +/* lead-in and lead-out give the scrub somewhere to run; on the original + page this space came from the neighbouring sections */ +/* Runway. + starts below the fold, but ENDS with every tile settled and visible — tail must stay, so the blank head and tail are trimmed rather than scrolled + through. Raise these to see more of the timeline, lower to scroll less. */ +.s6-content { position: relative; width: 100%; padding: 0; } +/* a full viewport tall, so the mark centres on the first screen rather than + sitting a quarter of the way down a short block */ +.s6-intro { + height: 100vh; + display: grid; + place-items: center; + align-content: center; + gap: 1.5rem; +} +.s6-outro { height: 108vh; display: grid; place-items: center; } + +.s6-logo { + width: min(62vw, 760px); + height: auto; + display: block; + user-select: none; + -webkit-user-select: none; +} +.s6-logo--sm { width: min(26vw, 300px); opacity: 0.4; } + +.s6-kicker { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + text-align: center; +} + +.s6-scroll { + margin: 0.6rem 0 3rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.34); + text-align: center; + position: relative; +} +.s6-scroll::after { + content: ""; + position: absolute; + top: calc(100% + 0.9rem); + left: 50%; + width: 1px; + height: 2.2rem; + background: rgba(255, 255, 255, 0.34); + animation: s6ScrollLine 2.1s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} +@keyframes s6ScrollLine { + 0% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 0%; } + 45% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 0%; } + 55% { transform: translateX(-50%) scaleY(1); transform-origin: 50% 100%; } + 100% { transform: translateX(-50%) scaleY(0); transform-origin: 50% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .s6-scroll::after { animation: none; transform: translateX(-50%); } +} + +.s6-section { position: relative; } + +.s6-title { + position: absolute; + height: 100vh; + width: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + padding: 0 10vw; + display: grid; + place-items: center; + text-align: center; + font-weight: 300; + font-size: clamp(1.5rem, 15vw, 6.5rem); + line-height: 1.05; + pointer-events: none; + z-index: 2; +} +.s6-title--top { align-items: start; } +.s6-title--bottom { align-items: end; } +.s6-title--left { justify-items: start; text-align: left; } +.s6-title--right { justify-items: end; text-align: right; } + +.s6-grid { + display: grid; + place-items: center; + padding: 2rem; + width: 100%; + perspective: var(--perspective); +} + +.s6-wrap { + height: var(--grid-height); + width: var(--grid-width); + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + gap: var(--grid-gap); + transform-style: preserve-3d; +} + +.s6-item { + aspect-ratio: var(--grid-item-ratio); + width: 100%; + height: auto; + overflow: hidden; + position: relative; + border-radius: 8px; + display: grid; + place-items: center; +} + +.s6-inner { + position: relative; + width: calc(1 / var(--grid-inner-scale) * 100%); + height: calc(1 / var(--grid-inner-scale) * 100%); + background-size: cover; + background-position: 50% 50%; +} +` diff --git a/content/registry/animations/scroll6-animation/meta.ts b/content/registry/animations/scroll6-animation/meta.ts new file mode 100644 index 0000000..a8c5894 --- /dev/null +++ b/content/registry/animations/scroll6-animation/meta.ts @@ -0,0 +1,27 @@ +export const meta = { + slug: "scroll6-animation", + title: "Scroll 6 Animation", + description: + "Tiles hinge from a pivot below themselves, swinging up while spinning a full turn on a stagger — a deck flipping itself into place. Inspired by Codrops.", + category: "animations", + preview: "/previews/animations/scroll6-animation/preview.mov", + github: "vij-sameerb5", + tags: [ + "animation", + "gsap", + "scrolltrigger", + "scroll", + "grid", + "3d", + "flip", + "stagger", + "spin", + "dark", + ], + dependencies: ["gsap", "lenis"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/grid-sections/paper-grid/demo.tsx b/content/registry/grid-sections/paper-grid/demo.tsx new file mode 100644 index 0000000..3393216 --- /dev/null +++ b/content/registry/grid-sections/paper-grid/demo.tsx @@ -0,0 +1,162 @@ +"use client" + +import { PaperSheets, type SheetContent } from "./ui/paper-sheets" + +/* ───────────────────────────────────────────── + paper-grid — three certificates printed on glass + + Three translucent sheets hang side by side over + a single giant wordmark. Each one bends like real + paper: the curl is integrated along the sheet + rather than pushed in Z, so the silhouette pulls + in where the page turns away from the light. + + Drag a sheet to turn it — the throw carries and + settles on the nearest whole rotation. Hover to + light it; the hovered sheet lifts toward the + camera so it wins the overlap. + + All three share one WebGL context, one renderer + and one environment map. The component measures + its own container and binds pointer events to it, + so it never touches window scroll or position:fixed. + + 3D scene ported from ThreeUI's + (MIT — Meng To / Design+Code). + ───────────────────────────────────────────── */ + +/** the same credits are printed on every sheet */ +const CREDITS = ["By Zepa Studios", "Sameer Shaik", "George Hastings", "Frank Adl"] + +const CARDS: SheetContent[] = [ + { + kicker: "Component of the Week.", + date: "Feb 14, 2026", + title: "selfie-hero.", + names: CREDITS, + body: "The Zepa jury is proud to declare this component pick of the week in recognition of the craft and restraint invested in its making.", + mark: "zepa.", + markSuffix: " winners", + badge: "2026 Official Certificate", + tag: "COTW", + }, + { + kicker: "Studio of the Month.", + date: "Mar 02, 2026", + title: "Zepa Studio.", + names: CREDITS, + body: "Awarded for a body of work that treats motion as structure rather than decoration, and ships it without the maintenance tax.", + mark: "zepa.", + markSuffix: " studios", + badge: "2026 Official Certificate", + tag: "SOTM", + }, + { + kicker: "Featured Build.", + date: "Apr 21, 2026", + title: "zepa.design", + names: CREDITS, + body: "Recognised for an interface that stays legible at speed, and for proving that a registry component can carry a whole product.", + mark: "zepa.", + markSuffix: " featured", + badge: "2026 Official Certificate", + tag: "BUILD", + }, +] + +export default function PaperGrid() { + return ( +
+ + + {/* one giant word, nothing else */} +
+

NOCTURNE

+
+ + {/* a touch of defocus so the wordmark sits behind glass */} +
+ + + +
+
+ +

+ Drag to turn one  ·  Hover to light it +

+
+ ) +} + +const CSS = ` +.pg-root { + position: relative; + width: 100%; + height: 100vh; + overflow: hidden; + background: #08080a; + color: #f2f2f0; + font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; + touch-action: none; +} + +/* the word the sheets hang in front of */ +.pg-bg { position: absolute; inset: 0; z-index: 1; overflow: hidden; } +.pg-bg h1 { + position: absolute; left: 50%; top: 50%; + transform: translate(-50%, -50%); + margin: 0; + font-weight: 600; letter-spacing: -0.055em; + font-size: clamp(96px, 18.6vw, 420px); + line-height: 0.8; white-space: nowrap; + color: rgba(242, 242, 240, 0.125); +} + +.pg-dof { + position: absolute; inset: -30px; z-index: 2; pointer-events: none; + backdrop-filter: blur(2.4px); + -webkit-backdrop-filter: blur(2.4px); + background: rgba(8, 8, 10, 0.05); +} + +/* the WebGL stage owns pointer input, so it sits above the backdrop */ +.pg-stage { position: absolute; inset: 0; z-index: 3; } +.pg-gl { display: block; width: 100%; height: 100%; } + +.pg-grain { + position: absolute; inset: 0; z-index: 4; pointer-events: none; + opacity: 0.05; mix-blend-mode: overlay; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; background-size: 160px 160px; +} + +.pg-vig { + position: absolute; inset: 0; z-index: 5; pointer-events: none; + background: radial-gradient(120% 90% at 50% 45%, rgba(0,0,0,0) 42%, rgba(0,0,0,0.52) 100%); +} + +.pg-hint { + position: absolute; left: 0; right: 0; bottom: 48px; z-index: 6; + margin: 0; pointer-events: none; text-align: center; + font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; + color: rgba(242, 242, 240, 0.3); + opacity: 0; transition: opacity 0.8s ease; +} +.pg-hint b { font-weight: 500; color: rgba(242, 242, 240, 0.58); } +.pg-stage[data-ready="true"] ~ .pg-hint { opacity: 1; } + +@media (hover: none) { .pg-ptr { display: none; } } + +@media (max-width: 760px) { + .pg-bg h1 { font-size: 22vw; } + .pg-hint { bottom: 32px; } +} + +@media (prefers-reduced-motion: reduce) { + .pg-hint { transition: none; } +} +` diff --git a/content/registry/grid-sections/paper-grid/meta.ts b/content/registry/grid-sections/paper-grid/meta.ts new file mode 100644 index 0000000..8b98b2a --- /dev/null +++ b/content/registry/grid-sections/paper-grid/meta.ts @@ -0,0 +1,29 @@ +export const meta = { + slug: "paper-grid", + title: "Paper Grid", + description: + "Three translucent certificates printed on glass, hanging side by side and bending like real paper. Drag a sheet to turn it, hover to light it — all three share one WebGL context. Inspired by ThreeUI's ThreeDPaper.", + category: "grid-sections", + preview: "/previews/grid-sections/paper-grid/preview.mov", + github: "vij-sameerb5", + tags: [ + "grid", + "webgl", + "three", + "paper", + "glass", + "certificate", + "shader", + "drag", + "3d", + "dark", + "interactive", + "animated", + ], + dependencies: ["three"], + registryDependencies: [], + version: 1, + views: 0, + likes: 0, + installs: 0, +} as const diff --git a/content/registry/grid-sections/paper-grid/ui/paper-sheets.tsx b/content/registry/grid-sections/paper-grid/ui/paper-sheets.tsx new file mode 100644 index 0000000..a99d586 --- /dev/null +++ b/content/registry/grid-sections/paper-grid/ui/paper-sheets.tsx @@ -0,0 +1,908 @@ +"use client" + +import { useEffect, useRef } from "react" +import * as THREE from "three" + +/* ───────────────────────────────────────────────────────────────────────── + paper-sheets — three certificates printed on glass, one WebGL context + + Ported from ThreeUI's (MIT, Meng To / Design+Code). + https://threeui.com/source-code/3d-paper.json + + The upstream component ships as an