Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


Flowers pageview on GitHub ↗

Reads every photo from public/flowers on the server and feeds them to a scroll-driven portrait wall. Captions off — these are flowers, not speakers.

import type { Metadata } from 'next';

import { Jsonld } from '@/components/Jsonld';
import { PageFrame } from '@/components/shell/PageFrame';
import { ScrollPortraitWall, type Speaker } from '@/components/ui/scroll-portrait-wall';
import { getFlowerImages } from '@/lib/flowers';
import type { CodeRef, TaggingData } from '@/lib/lens';
import { normalizeView } from '@/lib/lens';
import { absoluteUrl, pageMetadata, SITE, type JsonLd } from '@/lib/seo';

const TITLE = 'Flowers';
const DESCRIPTION =
  'A scrolling wall of flower photographs by kaspirius — they grow in, peak, and fade as you scroll.';

export const metadata: Metadata = pageMetadata({
  title: TITLE,
  description: DESCRIPTION,
  path: '/flowers',
});

const CODE_REFS: CodeRef[] = [
  {
    title: 'Flowers page',
    file: 'app/flowers/page.tsx',
    explanation:
      'Reads every photo from public/flowers on the server and feeds them to a scroll-driven portrait wall. Captions off — these are flowers, not speakers.',
  },
  {
    title: 'flowers manifest',
    file: 'lib/flowers.ts',
    explanation:
      'Lists the photos in public/flowers. Adding a flower is dropping a file in — no code change.',
  },
  {
    title: 'ScrollPortraitWall',
    file: 'components/ui/scroll-portrait-wall.tsx',
    explanation:
      'A GSAP ScrollTrigger wall: each portrait scrubs scale 0→1→0 across its pass through the viewport. Swapped to next/image (fill + sizes) so a wall of hundreds lazy-loads resized, optimized variants instead of full-size originals.',
    upstream: 'https://21st.dev/r/ruixen.ui/scroll-portrait-wall',
  },
];

/** Fisher–Yates shuffle (new array; runs per request on the server). */
function shuffle<T>(input: T[]): T[] {
  const arr = [...input];
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

export default async function FlowersPage({
  searchParams,
}: {
  searchParams: Promise<{ view?: string }>;
}) {
  const { view: rawView } = await searchParams;
  const view = normalizeView(rawView);

  // Shuffle on every load so the wall reads differently each visit. This page
  // is dynamic (reads searchParams), so the server re-renders per request and
  // the client hydrates from this exact order — no mismatch.
  const images = shuffle(getFlowerImages());
  const speakers: Speaker[] = images.map((src, i) => ({
    name: `Flower ${i + 1}`,
    role: '',
    src,
  }));

  const jsonLd: JsonLd[] = [
    {
      '@context': 'https://schema.org',
      '@type': 'ImageGallery',
      name: `${TITLE} — kaspirius`,
      description: DESCRIPTION,
      url: absoluteUrl('/flowers'),
      numberOfItems: images.length,
      author: { '@type': 'Person', name: SITE.author, url: SITE.url },
    },
  ];

  const tagging: TaggingData = {
    title: TITLE,
    description: `${DESCRIPTION} (${images.length} photographs)`,
    canonical: absoluteUrl('/flowers'),
    headings: [{ level: 1, text: TITLE }],
    jsonLd,
  };

  return (
    <>
      <Jsonld data={jsonLd} />
      <PageFrame
        view={view}
        basePath="/flowers"
        content={
          <ScrollPortraitWall
            title={TITLE}
            hint="scroll through the flowers"
            date={`${images.length} photographs`}
            speakers={speakers}
            columns={4}
            showCaptions={false}
          />
        }
        tagging={tagging}
        code={CODE_REFS}
      />
    </>
  );
}
flowers manifestview on GitHub ↗

Lists the photos in public/flowers. Adding a flower is dropping a file in — no code change.

/* ──────────────────────────────────────────────────────────────────────────
   lib/flowers.ts — the flower photo wall, via the shared media manifest.
   ────────────────────────────────────────────────────────────────────────── */

import { getMedia } from '@/lib/media';

/** Public URLs of every flower photo. */
export function getFlowerImages(): string[] {
  return getMedia('flowers').map((m) => m.src);
}
ScrollPortraitWallview on GitHub ↗

A GSAP ScrollTrigger wall: each portrait scrubs scale 0→1→0 across its pass through the viewport. Swapped to next/image (fill + sizes) so a wall of hundreds lazy-loads resized, optimized variants instead of full-size originals.

Based on the original source.

"use client";

import * as React from "react";
import Image from "next/image";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

import { cn } from "@/lib/utils";

if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger);
}

export interface Speaker {
  name: string;
  role: string;
  /** Image or video URL. Square / portrait crops look best. */
  src: string;
}

const VIDEO_RE = /\.(mp4|webm|mov)$/i;

const MEDIA_CLASS =
  "object-cover grayscale contrast-[1.15] filter transition-[transform,filter] duration-500 ease-in-out hover:scale-95 hover:grayscale-0 hover:contrast-100";

/** Muted, looping video that loads + plays only while near the viewport, so a
 *  wall of dozens of clips never downloads or plays them all at once. */
function WallVideo({ src, label }: { src: string; label: string }) {
  const ref = React.useRef<HTMLVideoElement | null>(null);
  const [load, setLoad] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setLoad(true);
          el.play().catch(() => {});
        } else {
          el.pause();
        }
      },
      { rootMargin: "300px", threshold: 0.01 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <video
      ref={ref}
      src={load ? src : undefined}
      aria-label={label}
      autoPlay
      muted
      loop
      playsInline
      preload="none"
      className={cn("h-full w-full", MEDIA_CLASS)}
    />
  );
}

export interface ScrollPortraitWallProps {
  /** Big sticky title rendered with `mix-blend-exclusion`. */
  title?: React.ReactNode;
  /** Small line under the title. */
  date?: React.ReactNode;
  /** Scroll hint that fades out as the wall comes into view. */
  hint?: React.ReactNode;
  /** People to scatter across the wall. Defaults to a built-in demo set. */
  speakers?: Speaker[];
  /** Columns on large screens (auto-reduced to 3 on `sm` and 2 on mobile). */
  columns?: number;
  /** Show the name / role caption under each portrait. Default `true`. */
  showCaptions?: boolean;
  className?: string;
}

/* Deterministic placement so SSR and client agree (no Math.random):
 * one portrait per row, with every third row holding a second one,
 * columns walked in a scattered pattern. Returns a grid of speaker
 * indices (or -1 for an empty cell). */
function buildLayout(count: number, cols: number): number[][] {
  const rows: number[][] = [];
  let i = 0;
  let r = 0;
  while (i < count) {
    const row = new Array<number>(cols).fill(-1);
    const a = (r * 2 + (r % 2)) % cols;
    row[a] = i++;
    if (r % 3 === 0 && i < count) {
      let b = (a + 2) % cols;
      if (b === a) b = (a + 1) % cols;
      row[b] = i++;
    }
    rows.push(row);
    r++;
  }
  return rows;
}

/* Keep portraits a usable size: cap the desired column count on smaller
 * viewports. Starts from `desired` so the SSR markup matches the first
 * client render, then narrows after mount. */
function useResponsiveColumns(desired: number): number {
  const [cols, setCols] = React.useState(desired);

  React.useEffect(() => {
    const sm = window.matchMedia("(min-width: 640px)");
    const lg = window.matchMedia("(min-width: 1024px)");
    const update = () => {
      if (lg.matches) setCols(desired);
      else if (sm.matches) setCols(Math.min(desired, 3));
      else setCols(Math.min(desired, 2));
    };
    update();
    sm.addEventListener("change", update);
    lg.addEventListener("change", update);
    return () => {
      sm.removeEventListener("change", update);
      lg.removeEventListener("change", update);
    };
  }, [desired]);

  return cols;
}

const DEMO_SPEAKERS: Speaker[] = [
  { name: "Alex Johnson", role: "CEO & Founder" },
  { name: "Sarah Chen", role: "CTO" },
  { name: "Marcus Rivera", role: "Lead Designer" },
  { name: "Emily Watson", role: "Product Manager" },
  { name: "David Kim", role: "Senior Developer" },
  { name: "Lisa Thompson", role: "Marketing Director" },
  { name: "James Wilson", role: "UX Researcher" },
  { name: "Rachel Green", role: "Data Scientist" },
  { name: "Michael Brown", role: "DevOps Engineer" },
  { name: "Anna Davis", role: "Content Strategist" },
].map((s, i) => ({
  ...s,
  // 5 avatars on the CDN, cycled across the speakers.
  src: `https://pub-940ccf6255b54fa799a9b01050e6c227.r2.dev/avatar-images/avatar-${String((i % 5) + 1).padStart(2, "0")}.jpg`,
}));

export function ScrollPortraitWall({
  title = "Speakers",
  date = "Oct 22, 2025",
  hint = "scroll down to see effect",
  speakers = DEMO_SPEAKERS,
  columns = 4,
  showCaptions = true,
  className,
}: ScrollPortraitWallProps) {
  const root = React.useRef<HTMLElement | null>(null);
  const hintRef = React.useRef<HTMLDivElement | null>(null);
  const cols = useResponsiveColumns(Math.max(1, columns));
  const layout = React.useMemo(
    () => buildLayout(speakers.length, cols),
    [speakers.length, cols],
  );

  useGSAP(
    () => {
      const reduce = window.matchMedia(
        "(prefers-reduced-motion: reduce)",
      ).matches;
      const items = gsap.utils.toArray<HTMLElement>(".spw-item");

      if (reduce) {
        gsap.set(items, { scale: 1 });
        return;
      }

      // Hint fades away over the first stretch of scrolling.
      gsap.to(hintRef.current, {
        autoAlpha: 0,
        ease: "none",
        scrollTrigger: {
          trigger: root.current,
          start: "top top",
          end: "+=40%",
          scrub: true,
        },
      });

      // Each portrait scrubs scale 0 → 1 → 0 across its full pass through the
      // viewport: it grows in from its transform-origin corner, peaks at
      // centre, then shrinks away — "comes and goes".
      items.forEach((el) => {
        gsap
          .timeline({
            scrollTrigger: {
              trigger: el,
              start: "top bottom",
              end: "bottom top",
              scrub: true,
            },
          })
          .fromTo(
            el,
            { scale: 0 },
            { scale: 1, ease: "power2.out", duration: 0.5 },
          )
          .to(el, { scale: 0, ease: "power2.in", duration: 0.5 });
      });
    },
    { scope: root, dependencies: [cols], revertOnUpdate: true },
  );

  return (
    <section
      ref={root}
      aria-label={typeof title === "string" ? title : undefined}
      className={cn("relative w-full bg-background text-foreground", className)}
    >
      {/* Scroll hint, lower-centre of the first screen, fading on scroll */}
      <div
        ref={hintRef}
        className="pointer-events-none absolute left-1/2 top-[60vh] grid -translate-x-1/2 content-start justify-items-center gap-6 text-center"
      >
        <span className="relative max-w-[12ch] text-xs uppercase leading-tight text-muted-foreground after:absolute after:left-1/2 after:top-full after:h-16 after:w-px after:bg-gradient-to-b after:from-transparent after:to-muted-foreground/40 after:content-['']">
          {hint}
        </span>
      </div>

      {/* Sticky centred title — inverts against whatever portrait is behind it */}
      <div className="pointer-events-none sticky top-1/2 z-20 -translate-y-1/2 text-center text-white mix-blend-exclusion">
        <h2 className="text-5xl font-semibold tracking-tighter sm:text-7xl md:text-8xl lg:text-9xl">
          {title}
        </h2>
        {date && (
          <p className="mt-1 text-xs uppercase tracking-wide text-white/60 sm:text-sm">
            {date}
          </p>
        )}
      </div>

      {/* The scattered portrait grid */}
      <div className="relative z-0 mb-[50vh] mt-[50vh]">
        {layout.map((row, ri) => (
          <div key={ri} className="flex w-full">
            {row.map((idx, ci) => {
              if (idx === -1)
                return <div key={ci} className="aspect-square flex-1" />;

              const s = speakers[idx];
              const origin = ci < cols / 2 ? "right bottom" : "left bottom";

              return (
                <div key={ci} className="aspect-square flex-1">
                  <div
                    className="spw-item relative h-full w-full"
                    style={{ transformOrigin: origin, transform: "scale(0)" }}
                  >
                    {VIDEO_RE.test(s.src) ? (
                      <WallVideo src={s.src} label={s.name} />
                    ) : (
                      /* next/image: optimized + resized + lazy by default, so a
                         wall of hundreds never ships full-size originals. */
                      <Image
                        src={s.src}
                        alt={s.name}
                        fill
                        sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
                        draggable={false}
                        className={MEDIA_CLASS}
                      />
                    )}
                    {showCaptions && (
                      <div className="absolute -bottom-2 left-0 flex w-full translate-y-full justify-between gap-2 text-[11px] uppercase leading-tight text-muted-foreground sm:text-sm">
                        <span className="truncate">{s.name}</span>
                        <span className="shrink-0">({s.role})</span>
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        ))}
      </div>
    </section>
  );
}

export default ScrollPortraitWall;
↖ kaspirius