Blocks

Layout 421

overlapping
1 ColumnText Align CenterImage/Video BottomMultiple ImagesImage
Layout 421

Code

relume-layout421.tsx
"use client";

import { motion, useScroll, useTransform } from "motion/react";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button, type ButtonProps } from "@/components/ui/button";
import { ChevronRight } from "relume-icons";
import { useRef } from "react";

type ImageProps = {
  src: string;
  alt?: string;
};

type Props = {
  tagline: string;
  heading: string;
  description: string;
  buttons: ButtonProps[];
  images: ImageProps[];
};

export type Layout421Props = React.ComponentPropsWithoutRef<"section"> & Partial<Props>;

type TransformConfig = { x: string[]; y: string[]; r: string[] };

export const Layout421 = (props: Layout421Props) => {
  const { tagline, heading, description, buttons, images } = {
    ...Layout421Defaults,
    ...props,
  };

  const isMobile = useMediaQuery("(max-width: 479px)");
  const sectionRef = useRef<HTMLElement | null>(null);
  const { scrollYProgress } = useScroll({
    target: sectionRef,
    offset: ["start start", "end end"],
  });

  const containerMotion = {
    opacity: useTransform(scrollYProgress, [0, 0.5], [1, 0]),
    scale: useTransform(scrollYProgress, [0, 0.5], [1, 0.95]),
  };

  const createTransform = ({ x, y, r }: TransformConfig) => ({
    translateX: useTransform(scrollYProgress, [0, 1], x),
    translateY: useTransform(scrollYProgress, [0, 1], y),
    rotateZ: useTransform(scrollYProgress, [0, 1], r),
  });

  const transformsConfig: Record<"mobile" | "desktop", Partial<TransformConfig>[]> = {
    mobile: [
      { x: ["0%", "0%"], y: ["10%", "10%"], r: ["0deg", "0deg"] },
      { x: ["13%", "90%"], y: ["12%", "80%"], r: ["0.6deg", "4deg"] },
      { x: ["-12%", "-80%"], y: ["-12%", "-80%"], r: ["-3deg", "4deg"] },
      { x: ["17.5%", "120%"], y: ["-6%", "-40%"], r: ["-0.6deg", "-6deg"] },
      { x: ["-17.5%", "-120%"], y: ["9%", "60%"], r: ["4.6deg", "8deg"] },
    ],
    desktop: [
      { x: ["0%", "0%"], y: ["10%", "10%"], r: ["0deg", "0deg"] },
      { x: ["0%", "100%"], y: ["0%", "60%"], r: ["0deg", "-4deg"] },
      { x: ["0%", "-50%"], y: ["0%", "-90%"], r: ["4deg", "4deg"] },
      { x: ["0%", "140%"], y: ["0%", "-40%"], r: ["0deg", "-12deg"] },
      { x: ["0%", "-140%"], y: ["0%", "60%"], r: ["8deg", "8deg"] },
    ],
  };

  const deviceConfig = isMobile ? transformsConfig.mobile : transformsConfig.desktop;

  const imageTransforms = deviceConfig.map((cfg) =>
    cfg.x ? createTransform(cfg as TransformConfig) : {},
  );

  return (
    <section ref={sectionRef} className="relative flex flex-col">
      <motion.div
        className="sticky top-0 z-0 mx-auto flex min-h-0 items-center justify-center md:min-h-[auto]"
        style={containerMotion}
      >
        <div className="py-16 text-center md:py-24 lg:py-28">
          <div className="mx-auto w-full max-w-lg px-[5%]">
            <p className="mb-3 font-semibold md:mb-4">{tagline}</p>
            <h1 className="mb-5 text-h2 font-bold md:mb-6">{heading}</h1>
            <p className="relative z-20 text-medium">{description}</p>
            <div className="relative z-20 mt-6 flex items-center justify-center gap-x-4 md:mt-8">
              {buttons.map((button, index) => (
                <Button key={index} {...button}>
                  {button.title}
                </Button>
              ))}
            </div>
          </div>
        </div>
      </motion.div>

      <div className="sticky top-0 z-10 -mt-20 flex h-[60svh] flex-col justify-center sm:mt-0 sm:h-[80svh] md:h-[70svh] lg:h-[120vh] lg:justify-normal">
        <div className="relative flex size-full items-center justify-center">
          {images.map((image, index) => (
            <motion.div
              key={index}
              className="absolute w-full max-w-[55vw] md:max-w-[35vw] lg:max-w-[30vw]"
              style={imageTransforms[index]}
            >
              <img src={image.src} alt={image.alt} className="size-full rounded-image" />
            </motion.div>
          ))}
        </div>
      </div>

      <div className="absolute inset-0 -z-10 mt-[80vh] sm:mt-[100vh]" />
    </section>
  );
};

export const Layout421Defaults: Props = {
  tagline: "Tagline",
  heading: "Medium length section heading goes here",
  description:
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat.",
  buttons: [
    { title: "Button", variant: "secondary" },
    {
      title: "Button",
      variant: "link",
      size: "link",
      iconRight: <ChevronRight className="text-scheme-text" />,
    },
  ],
  images: [
    {
      src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image.svg",
      alt: "Relume placeholder image 1",
    },
    {
      src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image.svg",
      alt: "Relume placeholder image 2",
    },
    {
      src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image.svg",
      alt: "Relume placeholder image 3",
    },
    {
      src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image.svg",
      alt: "Relume placeholder image 4",
    },
    {
      src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image.svg",
      alt: "Relume placeholder image 5",
    },
  ],
};

npm i @radix-ui/react-slot class-variance-authority@0.7.1 clsx@^2.1.1 motion@^12.15.0 relume-icons@1.3.0 tailwind-merge@^2.2.2