Blocks

Layout 487

off_grid
Text Align Left3 ColumnsImage/Video CenterButtonsMultiple ImagesImage
Layout 487

Code

relume-layout487.tsx
"use client";

import { useRef, useState, useEffect } from "react";
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";

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

type ContentBlockProps = {
  tagline: string;
  heading: string;
  description: string;
  buttons: ButtonProps[];
  image: ImageProps;
};

type Props = {
  tagline: string;
  heading: string;
  description: string;
  contentBlocks: ContentBlockProps[];
};

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

export const Layout487 = (props: Layout487Props) => {
  const { tagline, heading, description, contentBlocks } = {
    ...Layout487Defaults,
    ...props,
  };

  const [currentImageIndex, setCurrentImageIndex] = useState(0);
  const isMobile = useMediaQuery("(max-width: 767px)");

  const handleVisibilityChange = (index: number, isVisible: boolean) => {
    if (isVisible) {
      setCurrentImageIndex(index);
    }
  };

  return (
    <section className="relative px-[5%] py-16 md:py-24 lg:py-28">
      <div className="container">
        <div className="mx-auto max-w-lg text-center">
          <p className="mb-3 font-semibold md:mb-4">{tagline}</p>
          <h2 className="mb-5 text-h2 font-bold md:mb-6">{heading}</h2>
          <p className="text-medium">{description}</p>
        </div>
        <div className="mt-12 grid grid-cols-1 items-start gap-12 md:mt-0 md:grid-cols-3 md:gap-x-8 md:gap-y-16 lg:gap-x-12">
          <div className="relative flex w-full flex-col gap-12 md:pt-[60vh]">
            {contentBlocks.map((contentBlock, index) =>
              isMobile || index % 2 === 0 ? (
                <div key={index} className="md:h-svh">
                  <ContentBlock
                    {...contentBlock}
                    onVisibilityChange={(isVisible) => handleVisibilityChange(index, isVisible)}
                  />
                </div>
              ) : null,
            )}
          </div>
          <div className="sticky top-0 hidden h-screen w-full items-center justify-center md:flex">
            <div className="aspect-[2/3]">
              <img
                src={contentBlocks[currentImageIndex].image.src}
                alt={contentBlocks[currentImageIndex].image.alt}
                className="size-full rounded-image object-cover"
              />
            </div>
          </div>
          <div className="relative hidden w-full flex-col gap-12 md:flex md:pt-[110vh]">
            {contentBlocks.map((contentBlock, index) =>
              index % 2 !== 0 ? (
                <div key={index} className="md:h-svh">
                  <ContentBlock
                    {...contentBlock}
                    onVisibilityChange={(isVisible) => handleVisibilityChange(index, isVisible)}
                  />
                </div>
              ) : null,
            )}
          </div>
        </div>
      </div>
    </section>
  );
};

const ContentBlock: React.FC<
  ContentBlockProps & { onVisibilityChange: (isVisible: boolean) => void }
> = (props) => {
  const blockRef = useRef<HTMLDivElement>(null);
  const isMobile = useMediaQuery("(max-width: 767px)");

  const { scrollYProgress } = useScroll({
    target: blockRef,
    offset: ["start end", "end start"],
  });

  const opacity = useTransform(scrollYProgress, [0, 0.33, 0.5, 0.66, 0.83], [0, 0, 1, 1, 0]);

  useEffect(() => {
    const unsubscribe = opacity.on("change", (v: any) => {
      props.onVisibilityChange(v > 0.5);
    });
    return () => unsubscribe();
  }, [opacity, props.onVisibilityChange]);

  return (
    <motion.div
      ref={blockRef}
      animate={isMobile ? { opacity: 1 } : undefined}
      style={!isMobile ? ({ opacity } as { opacity: any }) : undefined}
      className="flex flex-col items-start justify-center md:justify-start"
    >
      <div className="mb-8 md:hidden">
        <img src={props.image.src} alt={props.image.alt} className="rounded-image" />
      </div>
      <p className="mb-3 font-semibold md:mb-4">{props.tagline}</p>
      <h2 className="mb-5 text-h4 font-bold md:mb-6">{props.heading}</h2>
      <p className="text-medium">{props.description}</p>
      <div className="mt-6 flex items-center gap-x-4 md:mt-8">
        {props.buttons.map((button, index) => (
          <Button key={index} {...button}>
            {button.title}
          </Button>
        ))}
      </div>
    </motion.div>
  );
};

export const Layout487Defaults: Props = {
  tagline: "Tagline",
  heading: "Short heading goes here",
  description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
  contentBlocks: [
    {
      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.",
      buttons: [
        { title: "Button", variant: "secondary" },
        {
          title: "Button",
          variant: "link",
          size: "link",
          iconRight: <ChevronRight className="text-scheme-text" />,
        },
      ],
      image: {
        src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image-1.svg",
        alt: "Relume placeholder image 1",
      },
    },
    {
      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.",
      buttons: [
        { title: "Button", variant: "secondary" },
        {
          title: "Button",
          variant: "link",
          size: "link",
          iconRight: <ChevronRight className="text-scheme-text" />,
        },
      ],
      image: {
        src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image-2.svg",
        alt: "Relume placeholder image 2",
      },
    },
    {
      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.",
      buttons: [
        { title: "Button", variant: "secondary" },
        {
          title: "Button",
          variant: "link",
          size: "link",
          iconRight: <ChevronRight className="text-scheme-text" />,
        },
      ],
      image: {
        src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image-3.svg",
        alt: "Relume placeholder image 3",
      },
    },
    {
      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.",
      buttons: [
        { title: "Button", variant: "secondary" },
        {
          title: "Button",
          variant: "link",
          size: "link",
          iconRight: <ChevronRight className="text-scheme-text" />,
        },
      ],
      image: {
        src: "https://d22po4pjz3o32e.cloudfront.net/placeholder-image-4.svg",
        alt: "Relume placeholder image 4",
      },
    },
  ],
};

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