SectionsIntermediateApril 8, 2026

Sticky Cards Parallax

Cards stack as you scroll — each sticks to the top while the next one slides underneath. The image inside each card zooms from 2× to 1× as the card settles, and previous cards scale down slightly as new ones arrive.

View Full Demo →

Kickandbass

A motion-forward headless Shopify storefront built for a London-based music label. Every interaction is driven by an animation language developed from the brand's visual identity.

See more
Kickandbass

Westend

A nonprofit campaign site for a community arts organisation. The brief was to make the site feel alive — every scroll, hover, and transition reinforces the brand's energy.

See more
Westend

Delivrd

Brand identity and web for a logistics startup entering a crowded market. The challenge was to make 'fast and reliable' feel premium rather than generic.

See more
Delivrd

Socialstats

A data product for social media managers. Dense information, zero visual noise. The design system was built to make complex analytics feel intuitive at a glance.

See more
Socialstats

Kevin Davis

An artist portfolio for a London-based photographer and director. The goal was to let the work speak — minimal UI, maximum presence.

See more
Kevin Davis
index.jsx
"use client";
import { useRef } from "react";
import Image from "next/image";
import { motion, useScroll, useTransform } from "framer-motion";
import styles from "./styles.module.css";

// Internal Card — each card tracks its own entry progress for image zoom
function Card({ i, title, description, image, color, progress, range, targetScale }) {
  const cardRef = useRef(null);

  // Per-card: image scales from 2→1 as the card scrolls into its sticky position
  const { scrollYProgress: entryProgress } = useScroll({
    target: cardRef,
    offset: ["start end", "start start"],
  });
  const imageScale = useTransform(entryProgress, [0, 1], [2, 1]);

  // Global: card scales down as subsequent cards stack on top
  const cardScale = useTransform(progress, range, [1, targetScale]);

  return (
    <div ref={cardRef} className={styles.cardContainer}>
      <motion.div
        className={styles.card}
        style={{
          backgroundColor: color,
          scale: cardScale,
          top: `calc(-5vh + ${i * 25}px)`,
        }}
      >
        <h2 className={styles.title}>{title}</h2>

        <div className={styles.body}>
          <div className={styles.description}>
            <p>{description}</p>
            <a href="#" className={styles.link}>
              See more{" "}
              <svg width="16" height="10" viewBox="0 0 22 12" fill="none">
                <path
                  d="M21.5303 6.53033C21.8232 6.23744 21.8232 5.76256 21.5303 5.46967L16.7574 0.696699C16.4645 0.403806 15.9896 0.403806 15.6967 0.696699C15.4038 0.989592 15.4038 1.46447 15.6967 1.75736L19.9393 6L15.6967 10.2426C15.4038 10.5355 15.4038 11.0104 15.6967 11.3033C15.9896 11.5962 16.4645 11.5962 16.7574 11.3033L21.5303 6.53033ZM0 6.75L21 6.75V5.25L0 5.25L0 6.75Z"
                  fill="currentColor"
                />
              </svg>
            </a>
          </div>

          <div className={styles.imageContainer}>
            <motion.div className={styles.imageInner} style={{ scale: imageScale }}>
              <Image src={image} alt={title} fill className={styles.img} sizes="40vw" />
            </motion.div>
          </div>
        </div>
      </motion.div>
    </div>
  );
}

export default function StickyCardsParallax({ cards = [] }) {
  const containerRef = useRef(null);

  // Track overall scroll to drive the card scale-down stacking effect
  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start start", "end end"],
  });

  return (
    <main ref={containerRef} className={styles.main}>
      {cards.map((card, i) => {
        const targetScale = 1 - (cards.length - i) * 0.05;
        return (
          <Card
            key={i}
            i={i}
            {...card}
            progress={scrollYProgress}
            range={[i / cards.length, 1]}
            targetScale={targetScale}
          />
        );
      })}
    </main>
  );
}
styles.module.css
.main {
  margin: 0;
  padding: 0;
}

/* ── One card per viewport height of scroll space ── */

.cardContainer {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.card {
  position: sticky;
  /* top is set via Framer Motion style prop: calc(-5vh + i * 25px) */
  width: min(90%, 1200px);
  height: 85vh;
  border-radius: 1.25rem;
  padding: 2rem 2.5rem;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  transform-origin: top center;
  overflow: hidden;
}

.title {
  font-size: clamp(1.5rem, 4vw, 3rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  color: #f5f5f5;
  margin: 0;
}

.body {
  display: flex;
  gap: 2rem;
  flex: 1;
  min-height: 0;
}

.description {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  color: rgba(255, 255, 255, 0.65);
  min-width: 0;
}

.description p {
  margin: 0;
  font-size: clamp(0.875rem, 1.25vw, 1rem);
  line-height: 1.65;
}

.link {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: #f5f5f5;
  text-decoration: none;
  font-size: 0.875rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
  padding-bottom: 0.25rem;
  align-self: flex-start;
  transition: border-color 0.2s;
}

.link:hover {
  border-color: rgba(255, 255, 255, 0.8);
}

.imageContainer {
  flex: 1.5;
  position: relative;
  border-radius: 0.75rem;
  overflow: hidden;
  min-height: 0;
}

.imageInner {
  position: absolute;
  inset: 0;
  /* Framer Motion applies scale here; oversize handled by container overflow:hidden */
}

.img {
  object-fit: cover;
}

@media (max-width: 768px) {
  .body {
    flex-direction: column;
  }

  .imageContainer {
    flex: none;
    height: 40vw;
  }
}
  • framer-motion

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.