Blocks

Layout 486

standard
Text Align Left2 ColumnsButtonsFeature Section
Layout 486

Code

relume-layout486.tsx
"use client";

import React, {
  useRef,
  useState,
  useCallback,
  useEffect,
  forwardRef,
  type ForwardedRef,
} from "react";
import {
  motion,
  useScroll,
  useSpring,
  useTransform,
  useMotionValueEvent,
  useMotionValue,
  animate,
} from "motion/react";
import { Button, type ButtonProps } from "@/components/ui/button";
import { ChevronRight } from "relume-icons";
import { useMediaQuery } from "@/hooks/use-media-query";

type Feature = {
  number: string;
  tagline: string;
  heading: string;
  description: string;
  buttons: ButtonProps[];
};

type Props = {
  features: Feature[];
};

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

export const Layout486 = (props: Layout486Props) => {
  const { features } = { ...Layout486Defaults, ...props };

  const { scrollY } = useScroll();

  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  const setCardRef = useCallback((el: HTMLDivElement | null, i: number) => {
    cardRefs.current[i] = el;
  }, []);

  const [activeIndex, setActiveIndex] = useState(0);
  const featureCount = features.length;
  const numbers = Array.from({ length: featureCount }, (_, index) => index + 1);

  const yProgress = useMotionValue(0);
  const y = useTransform(yProgress, (value) => `${value}%`);

  const updateActiveIndex = useCallback(() => {
    const triggerPoint = window.innerHeight * 0.3;
    let newActiveIndex = 0;

    for (let i = 0; i < cardRefs.current.length; i++) {
      const el = cardRefs.current[i];
      if (!el) continue;

      const rect = el.getBoundingClientRect();

      if (rect.top <= triggerPoint) {
        newActiveIndex = i;
      } else {
        break;
      }
    }

    setActiveIndex(newActiveIndex);
  }, []);

  useEffect(() => {
    const targetY = -((activeIndex * 100) / featureCount);
    animate(yProgress, targetY, {
      type: "spring",
      stiffness: 300,
      damping: 30,
    });
  }, [activeIndex, featureCount, yProgress]);

  useMotionValueEvent(scrollY, "change", () => {
    updateActiveIndex();
  });

  useEffect(() => {
    updateActiveIndex();
    const onResize = () => updateActiveIndex();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [updateActiveIndex]);

  return (
    <section className="px-[5%] py-16 md:py-24 lg:py-28">
      <div className="container">
        <div className="relative grid auto-cols-fr grid-cols-1 items-start gap-x-8 gap-y-12 md:grid-cols-[0.75fr_1fr] md:gap-y-16 lg:grid-cols-[max-content_1fr] lg:gap-x-20">
          <div className="static top-[20%] hidden h-56 overflow-hidden md:sticky md:flex md:items-start">
            <p className="text-[6rem] leading-none font-bold md:text-[14rem]">0</p>
            <motion.div className="text-center" style={{ y }}>
              {numbers.map((number, index) => (
                <p key={index} className="text-[6rem] leading-none font-bold md:text-[14rem]">
                  {number}
                </p>
              ))}
            </motion.div>
          </div>
          <div className="grid auto-cols-fr grid-cols-1 gap-x-12 gap-y-12 md:gap-x-28 md:gap-y-28">
            {features.map((feature, index) => (
              <FeatureCard key={index} ref={(el) => setCardRef(el, index)} {...feature} />
            ))}
          </div>
        </div>
      </div>
    </section>
  );
};

const FeatureCard = forwardRef(function FeatureCard(
  { ...feature }: Feature,
  ref: ForwardedRef<HTMLDivElement>,
) {
  const isMobile = useMediaQuery("(max-width: 767px)");
  const barRef = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: barRef,
    offset: isMobile ? ["start center", "end center"] : ["start 30%", "end 30%"],
  });
  const animatedWidth = useSpring(scrollYProgress, { stiffness: 100, damping: 20 });
  const width = { width: useTransform(animatedWidth, [0, 1], ["0%", "100%"]) };

  return (
    <div ref={ref} className="flex flex-col items-start justify-center py-8 md:py-0">
      <div className="mt-10 flex text-[6rem] leading-none font-bold md:mt-0 md:hidden md:text-[14rem]">
        {feature.number}
      </div>
      <div ref={barRef} className="mt-8 mb-8 h-0.5 w-full bg-neutral-lighter md:mt-0">
        <motion.div className="h-0.5 w-8 bg-scheme-text" style={width} />
      </div>
      <p className="mb-3 font-semibold md:mb-4">{feature.tagline}</p>
      <h2 className="mb-5 text-h2 font-bold md:mb-6">{feature.heading}</h2>
      <p className="text-medium">{feature.description}</p>
      <div className="mt-6 flex flex-wrap items-center gap-4 md:mt-8">
        {feature.buttons.map((button, index) => (
          <Button key={index} {...button}>
            {button.title}
          </Button>
        ))}
      </div>
    </div>
  );
});

export const Layout486Defaults: Props = {
  features: [
    {
      number: "01",
      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" />,
        },
      ],
    },
    {
      number: "02",
      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" />,
        },
      ],
    },
    {
      number: "03",
      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" />,
        },
      ],
    },
    {
      number: "04",
      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" />,
        },
      ],
    },
  ],
};

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