Pattern

Cover collage

Stills laid out like the game’s box art, assembled by the scroll.

betav0.1.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/cover-collage

Examples

Default

Open alone
Aşağı kaydır
Roleplay

API

<CoverCollage />

The page's GTA moment: stills laid out like the game's box art — hard black gutters, a tilted grid, the mark in the middle panel. The panels arrive scattered and the reader's scroll sets each one into place; under reduced motion they only fade.

PropTypeDefaultNotes
panels*CoverPanel[]—
classNamestring—
layoutClassNamestringaspect-[3/4] grid-cols-2 grid-rows-[1fr_1fr_1.4fr_1fr_1fr] [grid-template-areas:'a_a'_'b_c'_'e_e'_'d_f'_'g_h'] lg:aspect-[16/10] lg:grid-cols-4 lg:grid-rows-3 lg:[grid-template-areas:'a_a_b_c'_'d_e_e_c'_'d_f_g_h']The grid, as classes: rows, columns and `[grid-template-areas:…]` per breakpoint. The default is the box-art layout for seven panels (`a`–`h`, `e` for the title).
titleReactNode—What sits in the title area — a `FastBadge`, usually.
titleAreastringeThe grid area the title takes.

Dependencies

npm

  • motion@^12.40.0

Registry

Source

components/ds/cover-collage.tsxShow
'use client';

import { type MotionValue, motion, useReducedMotion, useScroll, useTransform } from 'motion/react';
import type * as React from 'react';
import { useRef } from 'react';
import { useScrollRoot } from '@/components/ds/scroll-root';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

export type CoverPanel = {
  src: string;
  alt?: string;
  /** Its `grid-area` name in the layout. */
  area: string;
  /** Where it is thrown in from before the cover assembles: pixels at 1080p, and degrees. */
  from?: { x: number; y: number; rotate: number };
};

export type CoverCollageProps = {
  panels: CoverPanel[];
  /** What sits in the title area — a `FastBadge`, usually. */
  title?: React.ReactNode;
  /** The grid area the title takes. */
  titleArea?: string;
  /**
   * The grid, as classes: rows, columns and `[grid-template-areas:…]` per breakpoint. The default
   * is the box-art layout for seven panels (`a`–`h`, `e` for the title).
   */
  layoutClassName?: string;
  className?: string;
};

const DEFAULT_LAYOUT =
  "aspect-[3/4] grid-cols-2 grid-rows-[1fr_1fr_1.4fr_1fr_1fr] [grid-template-areas:'a_a'_'b_c'_'e_e'_'d_f'_'g_h'] lg:aspect-[16/10] lg:grid-cols-4 lg:grid-rows-3 lg:[grid-template-areas:'a_a_b_c'_'d_e_e_c'_'d_f_g_h']";

function Panel({
  panel,
  progress,
  still,
}: {
  panel: CoverPanel;
  progress: MotionValue<number>;
  still: boolean;
}) {
  const from = still ? { x: 0, y: 0, rotate: 0 } : (panel.from ?? { x: 0, y: 0, rotate: 0 });
  // `--rest` runs 1 → 0 as the cover assembles. The throw is a calc on it times `--ui-px`, not
  // motion's x/y, so it scales with the FiveM NUI like every other length.
  const rest = useTransform(progress, [0, 1], [1, 0]);
  const opacity = useTransform(progress, [0, 0.5], [0, 1]);

  return (
    <motion.div
      style={
        {
          '--rest': rest,
          opacity,
          gridArea: panel.area,
          transform: `translate3d(calc(var(--rest) * ${from.x} * var(--ui-px, 1px)), calc(var(--rest) * ${from.y} * var(--ui-px, 1px)), 0) rotate(calc(var(--rest) * ${from.rotate}deg)) scale(calc(1 - var(--rest) * ${still ? 0 : 0.18}))`,
        } as unknown as React.CSSProperties
      }
      className="group relative overflow-hidden bg-ink"
    >
      <img
        src={panel.src}
        alt={panel.alt ?? ''}
        loading="lazy"
        className="absolute inset-0 size-full object-cover transition-transform duration-slower group-hover:scale-105"
      />
    </motion.div>
  );
}

/**
 * The page's GTA moment: stills laid out like the game's box art — hard black gutters, a tilted
 * grid, the mark in the middle panel. The panels arrive scattered and the reader's scroll sets
 * each one into place; under reduced motion they only fade.
 */
function CoverCollage({
  panels,
  title,
  titleArea = 'e',
  layoutClassName = DEFAULT_LAYOUT,
  className,
}: CoverCollageProps) {
  const root = useScrollRoot();
  const reduceMotion = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    container: root,
    target: ref,
    offset: ['start end', 'center center'],
  });
  const titleScale = useTransform(scrollYProgress, [0.3, 1], [reduceMotion ? 1 : 0.6, 1]);
  const titleOpacity = useTransform(scrollYProgress, [0.3, 0.8], [0, 1]);

  return (
    <div
      ref={ref}
      data-slot="cover-collage"
      className={cn(
        'mx-auto grid max-w-6xl -rotate-[1.5deg] gap-2 bg-black p-2 shadow-[0_2.5rem_6rem_-1.5rem_rgb(0_0_0/0.8)] md:gap-3 md:p-3',
        layoutClassName,
        className,
      )}
    >
      {panels.map((panel) => (
        <Panel
          key={`${panel.area}-${panel.src}`}
          panel={panel}
          progress={scrollYProgress}
          still={Boolean(reduceMotion)}
        />
      ))}
      {title && (
        <div style={{ gridArea: titleArea }} className="relative">
          <motion.div style={{ scale: titleScale, opacity: titleOpacity }} className="size-full">
            {title}
          </motion.div>
        </div>
      )}
    </div>
  );
}

export { CoverCollage };