# Hero Section 1

> Solace UI hero section block variation 1.

A shadcn/ui block from the [@solaceui](https://www.solaceui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/solaceui/hero-section-1).

- Page, with a live preview: https://shadcn.exchange/r/solaceui/hero-section-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/solaceui/hero-section-1-light-4ada3e80d69ed9db.png
- Registry item: https://www.solaceui.com/r/hero-section-1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.solaceui.com/r/hero-section-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `button`

## Files

### `src/registry/blocks/hero-section/one/index.tsx`

```tsx
"use client";

import React from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button"; // Adjust to your path
import { TextEffect } from "@/registry/blocks/hero-section/text-effect";
import { AnimatedGroup } from "@/components/ui/animated-group"; // Adjust to your path
import CircularGallery from "@/registry/blocks/hero-section/one/circular-gallery";
import { Variants } from "motion/react";

const transitionVariants: { item: Variants } = {
  item: {
    hidden: {
      opacity: 0,
      filter: "blur(12px)",
      y: 12,
    },
    visible: {
      opacity: 1,
      filter: "blur(0px)",
      y: 0,
      transition: {
        type: "spring",
        bounce: 0.3,
        duration: 1.5,
      },
    },
  },
};

export default function HeroSection1() {
  const defaultItems = [
    {
      image: `https://assets.solaceui.com/solaceui-Behance-screen.png`,
      text: "Behance",
    },
    {
      image: `https://assets.solaceui.com/solaceui-Notion-screen.png`,
      text: "Notion",
    },
    {
      image: `https://assets.solaceui.com/solaceui-One-screen.png`,
      text: "One",
    },
    {
      image: `https://assets.solaceui.com/solaceui-Reddit-nj7hwh.png`,
      text: "Reddit",
    },
    {
      image: `https://assets.solaceui.com/solaceui-Behance-screen.png`,
      text: "Behance",
    },
    {
      image: `https://assets.solaceui.com/solaceui-Notion-screen.png`,
      text: "Notion",
    },
  ];

  return (
    <>
      <main className="z-10">
        <section>
          <div className="relative overflow-hidden pt-2 md:pt-4">
            {/* Constrained content for the hero text */}
            <div className="mx-auto max-w-7xl px-6">
              <div className="text-center sm:mx-auto lg:mr-auto lg:mt-0">
                <AnimatedGroup variants={transitionVariants}>
                  <Link
                    href="#link"
                    className="hover:bg-background dark:hover:border-t-border bg-muted group mx-auto flex w-fit items-center gap-4 rounded-full border p-1 pl-4 shadow-md shadow-zinc-950/5 transition-colors duration-300 dark:border-t-white/5 dark:shadow-zinc-950"
                  >
                    <span className="text-foreground text-sm">
                      Introducing Exclusive Curated UI Designs
                    </span>
                    <span className="dark:border-background block h-4 w-0.5 border-l bg-white dark:bg-zinc-700"></span>
                    <div className="bg-background group-hover:bg-muted size-6 overflow-hidden rounded-full duration-500">
                      <div className="flex w-12 -translate-x-1/2 duration-500 ease-in-out group-hover:translate-x-0">
                        <span className="flex size-6">
                          <ArrowRight className="m-auto size-3 dark:text-white text-black" />
                        </span>
                        <span className="flex size-6">
                          <ArrowRight className="m-auto size-3 dark:text-white text-black" />
                        </span>
                      </div>
                    </div>
                  </Link>
                </AnimatedGroup>

                <TextEffect
                  preset="fade-in-blur"
                  speedSegment={0.3}
                  as="h1"
                  className="mt-8 text-balance text-6xl dark:text-white text-black md:text-7xl lg:mt-16 xl:text-[5.25rem] "
                >
                  The Ultimate Library of Curated UI Designs
                </TextEffect>
                <TextEffect
                  per="line"
                  preset="fade-in-blur"
                  speedSegment={0.3}
                  delay={0.5}
                  as="p"
                  className="mx-auto mt-8 max-w-2xl text-balance text-lg dark:text-white/60 text-black"
                >
                  Explore hundreds of beautifully crafted user interfaces from
                  top designers around the world. Choose our handpicked gallery
                  of modern UI inspiration.
                </TextEffect>

                <AnimatedGroup
                  variants={{
                    container: {
                      visible: {
                        transition: {
                          staggerChildren: 0.05,
                          delayChildren: 0.75,
                        },
                      },
                    },
                    ...transitionVariants,
                  }}
                  className="mt-12 flex flex-col items-center justify-center gap-2 md:flex-row"
                >
                  <div key={1} className="">
                    <Button
                      asChild
                      size="lg"
                      className="rounded-md px-5 text-base dark:bg-white bg-black"
                    >
                      <Link href="#link">
                        <span className="text-nowra dark:text-black text-white">
                          Browse Designs
                        </span>
                      </Link>
                    </Button>
                  </div>
                  <Button
                    key={2}
                    asChild
                    size="lg"
                    variant="ghost"
                    className="rounded-md px-5 bg-foreground/10"
                  >
                    <Link href="#link">
                      <span className="text-nowrap dark:text-white text-black">
                        Submit Your Work
                      </span>
                    </Link>
                  </Button>
                </AnimatedGroup>
              </div>
            </div>

            <div className="mt-12 w-screen relative left-1/2 right-1/2 ml-[-50vw] mr-[-50vw]">
              <CircularGallery
                items={defaultItems}
                bend={-9}
                textColor="#ffffff"
                borderRadius={0.03}
              />
            </div>
          </div>
        </section>
      </main>
    </>
  );
}
```

### `src/registry/blocks/hero-section/one/circular-gallery.tsx`

```tsx
// Component from React Bits

// Install the dependency
// npm install ogl

"use client";
import { useRef, useEffect } from "react";
import {
    Renderer,
    Camera,
    Transform,
    Plane,
    Mesh,
    Program,
    Texture,
} from "ogl";

type GL = Renderer["gl"];

/**
 * Simple utility: debounce
 */
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
    let timeout: number;
    return function (this: any, ...args: Parameters<T>) {
        window.clearTimeout(timeout);
        timeout = window.setTimeout(() => func.apply(this, args), wait);
    };
}

/**
 * Linear interpolation
 */
function lerp(p1: number, p2: number, t: number): number {
    return p1 + (p2 - p1) * t;
}

/**
 * Auto-bind all class methods
 */
function autoBind(instance: any): void {
    const proto = Object.getPrototypeOf(instance);
    Object.getOwnPropertyNames(proto).forEach((key) => {
        if (key !== "constructor" && typeof instance[key] === "function") {
            instance[key] = instance[key].bind(instance);
        }
    });
}

/**
 * Extract a numeric px font size from a string like "bold 30px sans-serif"
 */
function getFontSize(font: string): number {
    const match = font.match(/(\d+)px/);
    return match ? parseInt(match[1], 10) : 30;
}

/**
 * Create a texture from text (to display image titles, etc.)
 */
function createTextTexture(
    gl: GL,
    text: string,
    font: string = "bold 30px monospace",
    color: string = "black"
): { texture: Texture; width: number; height: number } {
    const canvas = document.createElement("canvas");
    const context = canvas.getContext("2d");
    if (!context) throw new Error("Could not get 2d context");

    context.font = font;
    const metrics = context.measureText(text);
    const textWidth = Math.ceil(metrics.width);
    const fontSize = getFontSize(font);
    const textHeight = Math.ceil(fontSize * 1.2);

    canvas.width = textWidth + 20;
    canvas.height = textHeight + 20;

    context.font = font;
    context.fillStyle = color;
    context.textBaseline = "middle";
    context.textAlign = "center";
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.fillText(text, canvas.width / 2, canvas.height / 2);

    const texture = new Texture(gl, { generateMipmaps: false });
    texture.image = canvas;
    return { texture, width: canvas.width, height: canvas.height };
}

interface TitleProps {
    gl: GL;
    plane: Mesh;
    renderer: Renderer;
    text: string;
    textColor?: string;
    font?: string;
}

/**
 * Displays a text label beneath each image
 */
class Title {
    gl: GL;
    plane: Mesh;
    renderer: Renderer;
    text: string;
    textColor: string;
    font: string;
    mesh!: Mesh;

    constructor({
        gl,
        plane,
        renderer,
        text,
        textColor = "#545050",
        font = "30px sans-serif",
    }: TitleProps) {
        autoBind(this);
        this.gl = gl;
        this.plane = plane;
        this.renderer = renderer;
        this.text = text;
        this.textColor = textColor;
        this.font = font;
        this.createMesh();
    }

    createMesh() {
        const { texture, width, height } = createTextTexture(
            this.gl,
            this.text,
            this.font,
            this.textColor
        );
        const geometry = new Plane(this.gl);
        const program = new Program(this.gl, {
            vertex: `
        attribute vec3 position;
        attribute vec2 uv;
        uniform mat4 modelViewMatrix;
        uniform mat4 projectionMatrix;
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
            fragment: `
        precision highp float;
        uniform sampler2D tMap;
        varying vec2 vUv;
        void main() {
          vec4 color = texture2D(tMap, vUv);
          // Discard very transparent fragments
          if (color.a < 0.1) discard;
          gl_FragColor = color;
        }
      `,
            uniforms: { tMap: { value: texture } },
            transparent: true,
        });
        this.mesh = new Mesh(this.gl, { geometry, program });

        const aspect = width / height;
        // Make text ~15% of plane height
        const textHeightScaled = this.plane.scale.y * 0.15;
        const textWidthScaled = textHeightScaled * aspect;
        this.mesh.scale.set(textWidthScaled, textHeightScaled, 1);

        // Position the text slightly below the plane
        this.mesh.position.y =
            -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05;

        this.mesh.setParent(this.plane);
    }
}

interface ScreenSize {
    width: number;
    height: number;
}

interface Viewport {
    width: number;
    height: number;
}

interface MediaProps {
    geometry: Plane;
    gl: GL;
    image: string;
    index: number;
    length: number;
    renderer: Renderer;
    scene: Transform;
    screen: ScreenSize;
    text: string;
    viewport: Viewport;
    bend: number;
    textColor: string;
    borderRadius?: number;
    font?: string;
}

/**
 * Represents a single image + text in the circular gallery
 */
class Media {
    extra: number = 0;
    geometry: Plane;
    gl: GL;
    image: string;
    index: number;
    length: number;
    renderer: Renderer;
    scene: Transform;
    screen: ScreenSize;
    text: string;
    viewport: Viewport;
    bend: number;
    textColor: string;
    borderRadius: number;
    font?: string;
    program!: Program;
    plane!: Mesh;
    title!: Title;
    scale!: number;
    padding!: number;
    width!: number;
    widthTotal!: number;
    x!: number;
    speed: number = 0;
    isBefore: boolean = false;
    isAfter: boolean = false;

    constructor({
        geometry,
        gl,
        image,
        index,
        length,
        renderer,
        scene,
        screen,
        text,
        viewport,
        bend,
        textColor,
        borderRadius = 0,
        font,
    }: MediaProps) {
        this.geometry = geometry;
        this.gl = gl;
        this.image = image;
        this.index = index;
        this.length = length;
        this.renderer = renderer;
        this.scene = scene;
        this.screen = screen;
        this.text = text;
        this.viewport = viewport;
        this.bend = bend;
        this.textColor = textColor;
        this.borderRadius = borderRadius;
        this.font = font;

        this.createShader();
        this.createMesh();
        this.createTitle();
        this.onResize();
    }

    createShader() {
        const texture = new Texture(this.gl, { generateMipmaps: false });
        this.program = new Program(this.gl, {
            depthTest: false,
            depthWrite: false,
            vertex: `
        precision highp float;
        attribute vec3 position;
        attribute vec2 uv;
        uniform mat4 modelViewMatrix;
        uniform mat4 projectionMatrix;
        uniform float uTime;
        uniform float uSpeed;
        varying vec2 vUv;
        void main() {
          vUv = uv;
          vec3 p = position;
          // Subtle wave
          p.z = (sin(p.x * 4.0 + uTime) * 1.5 + cos(p.y * 2.0 + uTime) * 1.5) * (0.1 + uSpeed * 0.5);
          gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
        }
      `,
            fragment: `
        precision highp float;
        uniform vec2 uImageSizes;
        uniform vec2 uPlaneSizes;
        uniform sampler2D tMap;
        uniform float uBorderRadius;
        varying vec2 vUv;
        
        // Signed Distance Function for a rounded box
        float roundedBoxSDF(vec2 p, vec2 b, float r) {
          vec2 d = abs(p) - b;
          return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
        }
        
        void main() {
          // Fit the image while preserving aspect ratio
          vec2 ratio = vec2(
            min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
            min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
          );
          vec2 uv = vec2(
            vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
            vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
          );
          
          vec4 color = texture2D(tMap, uv);
          
          // Rounded corners in UV space
          float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius);
          if(d > 0.0) {
            discard;
          }
          
          gl_FragColor = vec4(color.rgb, 1.0);
        }
      `,
            uniforms: {
                tMap: { value: texture },
                uPlaneSizes: { value: [0, 0] },
                uImageSizes: { value: [0, 0] },
                uSpeed: { value: 0 },
                uTime: { value: 100 * Math.random() },
                uBorderRadius: { value: this.borderRadius },
            },
            transparent: true,
        });

        const img = new Image();
        img.crossOrigin = "anonymous";
        img.src = this.image;
        img.onload = () => {
            texture.image = img;
            this.program.uniforms.uImageSizes.value = [
                img.naturalWidth,
                img.naturalHeight,
            ];
        };
    }

    createMesh() {
        this.plane = new Mesh(this.gl, {
            geometry: this.geometry,
            program: this.program,
        });
        this.plane.setParent(this.scene);
    }

    createTitle() {
        this.title = new Title({
            gl: this.gl,
            plane: this.plane,
            renderer: this.renderer,
            text: this.text,
            textColor: this.textColor,
            font: this.font,
        });
    }

    update(
        scroll: { current: number; last: number },
        direction: "right" | "left"
    ) {
        // Move horizontally
        this.plane.position.x = this.x - scroll.current - this.extra;

        // Bend the carousel
        const x = this.plane.position.x;
        const H = this.viewport.width / 2;
        if (this.bend === 0) {
            this.plane.position.y = 0;
            this.plane.rotation.z = 0;
        } else {
            const B_abs = Math.abs(this.bend);
            const R = (H * H + B_abs * B_abs) / (2 * B_abs);
            const effectiveX = Math.min(Math.abs(x), H);
            const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);

            if (this.bend > 0) {
                this.plane.position.y = -arc;
                this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);
            } else {
                this.plane.position.y = arc;
                this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);
            }
        }

        // Animate wave
        this.speed = scroll.current - scroll.last;
        this.program.uniforms.uTime.value += 0.04;
        this.program.uniforms.uSpeed.value = this.speed;

        // Looping logic
        const planeOffset = this.plane.scale.x / 2;
        const viewportOffset = this.viewport.width / 2;
        this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;
        this.isAfter = this.plane.position.x - planeOffset > viewportOffset;

        if (direction === "right" && this.isBefore) {
            this.extra -= this.widthTotal;
            this.isBefore = this.isAfter = false;
        }
        if (direction === "left" && this.isAfter) {
            this.extra += this.widthTotal;
            this.isBefore = this.isAfter = false;
        }
    }

    onResize({
        screen,
        viewport,
    }: { screen?: ScreenSize; viewport?: Viewport } = {}) {
        if (screen) this.screen = screen;
        if (viewport) {
            this.viewport = viewport;
        }
        // Scale the images based on screen size
        this.scale = this.screen.height / 1500;
        this.plane.scale.y =
            (this.viewport.height * (900 * this.scale)) / this.screen.height;
        this.plane.scale.x =
            (this.viewport.width * (700 * this.scale)) / this.screen.width;

        this.program.uniforms.uPlaneSizes.value = [
            this.plane.scale.x,
            this.plane.scale.y,
        ];

        this.padding = 2;
        this.width = this.plane.scale.x + this.padding;
        this.widthTotal = this.width * this.length;
        this.x = this.width * this.index;
    }
}

interface AppConfig {
    items?: { image: string; text: string }[];
    bend?: number;
    textColor?: string;
    borderRadius?: number;
    font?: string;
}

/**
 * Main application controlling the scene, camera, and a set of Media items
 */
class App {
    container: HTMLElement;
    scroll: {
        ease: number;
        current: number;
        target: number;
        last: number;
        position?: number;
    };
    onCheckDebounce: (...args: any[]) => void;
    renderer!: Renderer;
    gl!: GL;
    camera!: Camera;
    scene!: Transform;
    planeGeometry!: Plane;
    medias: Media[] = [];
    mediasImages: { image: string; text: string }[] = [];
    screen!: { width: number; height: number };
    viewport!: { width: number; height: number };
    raf: number = 0;

    boundOnResize!: () => void;
    boundOnWheel!: () => void;
    boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void;
    boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void;
    boundOnTouchUp!: () => void;

    isDown: boolean = false;
    start: number = 0;

    constructor(
        container: HTMLElement,
        {
            items,
            bend = 1,
            textColor = "#ffffff",
            borderRadius = 0,
            font = "bold 30px DM Sans",
        }: AppConfig
    ) {
        document.documentElement.classList.remove("no-js");
        this.container = container;
        this.scroll = { ease: 0.05, current: 0, target: 0, last: 0 };
        this.onCheckDebounce = debounce(this.onCheck.bind(this), 200);

        this.createRenderer();
        this.createCamera();
        this.createScene();
        this.onResize();
        this.createGeometry();
        this.createMedias(items, bend, textColor, borderRadius, font);
        this.update();
        this.addEventListeners();
    }

    createRenderer() {
        this.renderer = new Renderer({ alpha: true });
        this.gl = this.renderer.gl;
        this.gl.clearColor(0, 0, 0, 0);
        this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement);
    }

    createCamera() {
        this.camera = new Camera(this.gl);
        this.camera.fov = 45;
        this.camera.position.z = 20;
    }

    createScene() {
        this.scene = new Transform();
    }

    createGeometry() {
        // Increase segments so the wave looks smoother
        this.planeGeometry = new Plane(this.gl, {
            heightSegments: 50,
            widthSegments: 100,
        });
    }

    createMedias(
        items: { image: string; text: string }[] | undefined,
        bend: number = 1,
        textColor: string,
        borderRadius: number,
        font: string
    ) {
        // If no items provided, fallback to placeholders
        const defaultItems = [
            {
                image: `https://picsum.photos/seed/5/800/600?grayscale`,
                text: "Deep Diving",
            },
            {
                image: `https://picsum.photos/seed/16/800/600?grayscale`,
                text: "Train Track",
            },
            {
                image: `https://picsum.photos/seed/17/800/600?grayscale`,
                text: "Santorini",
            },
            {
                image: `https://picsum.photos/seed/8/800/600?grayscale`,
                text: "Blurry Lights",
            },
            {
                image: `https://picsum.photos/seed/9/800/600?grayscale`,
                text: "New York",
            },
            {
                image: `https://picsum.photos/seed/10/800/600?grayscale`,
                text: "Good Boy",
            },
            {
                image: `https://picsum.photos/seed/21/800/600?grayscale`,
                text: "Coastline",
            },
            {
                image: `https://picsum.photos/seed/12/800/600?grayscale`,
                text: "Palm Trees",
            },
        ];

        const galleryItems = items && items.length ? items : defaultItems;
        // Duplicate items for infinite loop effect
        this.mediasImages = galleryItems.concat(galleryItems);

        this.medias = this.mediasImages.map((data, index) => {
            return new Media({
                geometry: this.planeGeometry,
                gl: this.gl,
                image: data.image,
                index,
                length: this.mediasImages.length,
                renderer: this.renderer,
                scene: this.scene,
                screen: this.screen,
                text: data.text,
                viewport: this.viewport,
                bend,
                textColor,
                borderRadius,
                font,
            });
        });
    }

    onTouchDown(e: MouseEvent | TouchEvent) {
        this.isDown = true;
        this.scroll.position = this.scroll.current;
        this.start = "touches" in e ? e.touches[0].clientX : e.clientX;
    }

    onTouchMove(e: MouseEvent | TouchEvent) {
        if (!this.isDown) return;
        const x = "touches" in e ? e.touches[0].clientX : e.clientX;
        const distance = (this.start - x) * 0.05;
        this.scroll.target = (this.scroll.position ?? 0) + distance;
    }

    onTouchUp() {
        this.isDown = false;
        this.onCheck();
    }

    onWheel() {
        // Scroll horizontally
        this.scroll.target += 2;
        this.onCheckDebounce();
    }

    onCheck() {
        // Snap to nearest image
        if (!this.medias.length) return;
        const width = this.medias[0].width;
        const itemIndex = Math.round(Math.abs(this.scroll.target) / width);
        const item = width * itemIndex;
        this.scroll.target = this.scroll.target < 0 ? -item : item;
    }

    onResize() {
        this.screen = {
            width: this.container.clientWidth,
            height: this.container.clientHeight,
        };
        this.renderer.setSize(this.screen.width, this.screen.height);

        // Update camera
        this.camera.perspective({
            aspect: this.screen.width / this.screen.height,
        });

        // Convert FOV to radians
        const fov = (this.camera.fov * Math.PI) / 180;
        const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
        const width = height * this.camera.aspect;
        this.viewport = { width, height };

        // Resize each Media
        this.medias.forEach((media) =>
            media.onResize({ screen: this.screen, viewport: this.viewport })
        );
    }

    update() {
        this.scroll.current = lerp(
            this.scroll.current,
            this.scroll.target,
            this.scroll.ease
        );

        const direction = this.scroll.current > this.scroll.last ? "right" : "left";
        this.medias.forEach((media) => media.update(this.scroll, direction));

        this.renderer.render({ scene: this.scene, camera: this.camera });
        this.scroll.last = this.scroll.current;
        this.raf = window.requestAnimationFrame(this.update.bind(this));
    }

    addEventListeners() {
        this.boundOnResize = this.onResize.bind(this);
        this.boundOnWheel = this.onWheel.bind(this);
        this.boundOnTouchDown = this.onTouchDown.bind(this);
        this.boundOnTouchMove = this.onTouchMove.bind(this);
        this.boundOnTouchUp = this.onTouchUp.bind(this);

        window.addEventListener("resize", this.boundOnResize);
        window.addEventListener("mousewheel", this.boundOnWheel);
        window.addEventListener("wheel", this.boundOnWheel);
        window.addEventListener("mousedown", this.boundOnTouchDown);
        window.addEventListener("mousemove", this.boundOnTouchMove);
        window.addEventListener("mouseup", this.boundOnTouchUp);
        window.addEventListener("touchstart", this.boundOnTouchDown);
        window.addEventListener("touchmove", this.boundOnTouchMove);
        window.addEventListener("touchend", this.boundOnTouchUp);
    }

    destroy() {
        window.cancelAnimationFrame(this.raf);
        window.removeEventListener("resize", this.boundOnResize);
        window.removeEventListener("mousewheel", this.boundOnWheel);
        window.removeEventListener("wheel", this.boundOnWheel);
        window.removeEventListener("mousedown", this.boundOnTouchDown);
        window.removeEventListener("mousemove", this.boundOnTouchMove);
        window.removeEventListener("mouseup", this.boundOnTouchUp);
        window.removeEventListener("touchstart", this.boundOnTouchDown);
        window.removeEventListener("touchmove", this.boundOnTouchMove);
        window.removeEventListener("touchend", this.boundOnTouchUp);

        if (this.renderer?.gl?.canvas?.parentNode) {
            this.renderer.gl.canvas.parentNode.removeChild(
                this.renderer.gl.canvas as HTMLCanvasElement
            );
        }
    }
}

interface CircularGalleryProps {
    items?: { image: string; text: string }[];
    bend?: number;
    textColor?: string;
    borderRadius?: number;
    font?: string;
}

/**
 * Public React component for the circular gallery
 */
export default function CircularGallery({
    items,
    bend = 3,
    textColor = "#ffffff",
    borderRadius = 0.05,
    font = "bold 30px DM Sans",
}: CircularGalleryProps) {
    const containerRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (!containerRef.current) return;
        const app = new App(containerRef.current, {
            items,
            bend,
            textColor,
            borderRadius,
            font,
        });

        return () => {
            app.destroy();
        };
    }, [items, bend, textColor, borderRadius, font]);

    return (
        <div
            className="w-full h-[400px] overflow-hidden cursor-grab active:cursor-grabbing"
            ref={containerRef}
        />
    );
}
```

### `src/registry/blocks/hero-section/text-effect.tsx`

```tsx
// Component From Motion Primitives

"use client";
import { cn } from "@/lib/utils";
import {
    AnimatePresence,
    motion,
    TargetAndTransition,
    Transition,
    Variant,
    Variants,
} from "motion/react";
import React from "react";

export type PresetType = "blur" | "fade-in-blur" | "scale" | "fade" | "slide";

export type PerType = "word" | "char" | "line";

export type TextEffectProps = {
    children: string;
    per?: PerType;
    as?: keyof React.JSX.IntrinsicElements;
    variants?: {
        container?: Variants;
        item?: Variants;
    };
    className?: string;
    preset?: PresetType;
    delay?: number;
    speedReveal?: number;
    speedSegment?: number;
    trigger?: boolean;
    onAnimationComplete?: () => void;
    onAnimationStart?: () => void;
    segmentWrapperClassName?: string;
    containerTransition?: Transition;
    segmentTransition?: Transition;
    style?: React.CSSProperties;
};

const defaultStaggerTimes: Record<PerType, number> = {
    char: 0.03,
    word: 0.05,
    line: 0.1,
};

const defaultContainerVariants: Variants = {
    hidden: { opacity: 0 },
    visible: {
        opacity: 1,
        transition: {
            staggerChildren: 0.05,
        },
    },
    exit: {
        transition: { staggerChildren: 0.05, staggerDirection: -1 },
    },
};

const defaultItemVariants: Variants = {
    hidden: { opacity: 0 },
    visible: {
        opacity: 1,
    },
    exit: { opacity: 0 },
};

const presetVariants: Record<
    PresetType,
    { container: Variants; item: Variants }
> = {
    blur: {
        container: defaultContainerVariants,
        item: {
            hidden: { opacity: 0, filter: "blur(12px)" },
            visible: { opacity: 1, filter: "blur(0px)" },
            exit: { opacity: 0, filter: "blur(12px)" },
        },
    },
    "fade-in-blur": {
        container: defaultContainerVariants,
        item: {
            hidden: { opacity: 0, y: 20, filter: "blur(12px)" },
            visible: { opacity: 1, y: 0, filter: "blur(0px)" },
            exit: { opacity: 0, y: 20, filter: "blur(12px)" },
        },
    },
    scale: {
        container: defaultContainerVariants,
        item: {
            hidden: { opacity: 0, scale: 0 },
            visible: { opacity: 1, scale: 1 },
            exit: { opacity: 0, scale: 0 },
        },
    },
    fade: {
        container: defaultContainerVariants,
        item: {
            hidden: { opacity: 0 },
            visible: { opacity: 1 },
            exit: { opacity: 0 },
        },
    },
    slide: {
        container: defaultContainerVariants,
        item: {
            hidden: { opacity: 0, y: 20 },
            visible: { opacity: 1, y: 0 },
            exit: { opacity: 0, y: 20 },
        },
    },
};

const AnimationComponent: React.FC<{
    segment: string;
    variants: Variants;
    per: "line" | "word" | "char";
    segmentWrapperClassName?: string;
}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
    const content =
        per === "line" ? (
            <motion.span variants={variants} className="block">
                {segment}
            </motion.span>
        ) : per === "word" ? (
            <motion.span
                aria-hidden="true"
                variants={variants}
                className="inline-block whitespace-pre"
            >
                {segment}
            </motion.span>
        ) : (
            <motion.span className="inline-block whitespace-pre">
                {segment.split("").map((char, charIndex) => (
                    <motion.span
                        key={`char-${charIndex}`}
                        aria-hidden="true"
                        variants={variants}
                        className="inline-block whitespace-pre"
                    >
                        {char}
                    </motion.span>
                ))}
            </motion.span>
        );

    if (!segmentWrapperClassName) {
        return content;
    }

    const defaultWrapperClassName = per === "line" ? "block" : "inline-block";

    return (
        <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
            {content}
        </span>
    );
});

AnimationComponent.displayName = "AnimationComponent";

const splitText = (text: string, per: "line" | "word" | "char") => {
    if (per === "line") return text.split("\n");
    return text.split(/(\s+)/);
};

const hasTransition = (
    variant: Variant
): variant is TargetAndTransition & { transition?: Transition } => {
    return (
        typeof variant === "object" && variant !== null && "transition" in variant
    );
};

const createVariantsWithTransition = (
    baseVariants: Variants,
    transition?: Transition & { exit?: Transition }
): Variants => {
    if (!transition) return baseVariants;

    const { exit: _, ...mainTransition } = transition;

    return {
        ...baseVariants,
        visible: {
            ...baseVariants.visible,
            transition: {
                ...(hasTransition(baseVariants.visible)
                    ? baseVariants.visible.transition
                    : {}),
                ...mainTransition,
            },
        },
        exit: {
            ...baseVariants.exit,
            transition: {
                ...(hasTransition(baseVariants.exit)
                    ? baseVariants.exit.transition
                    : {}),
                ...mainTransition,
                staggerDirection: -1,
            },
        },
    };
};

export function TextEffect({
    children,
    per = "word",
    as = "p",
    variants,
    className,
    preset = "fade",
    delay = 0,
    speedReveal = 1,
    speedSegment = 1,
    trigger = true,
    onAnimationComplete,
    onAnimationStart,
    segmentWrapperClassName,
    containerTransition,
    segmentTransition,
    style,
}: TextEffectProps) {
    const segments = splitText(children, per);
    const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;

    const baseVariants = preset
        ? presetVariants[preset]
        : { container: defaultContainerVariants, item: defaultItemVariants };

    const stagger = defaultStaggerTimes[per] / speedReveal;

    const baseDuration = 0.3 / speedSegment;

    const customStagger = hasTransition(variants?.container?.visible ?? {})
        ? (variants?.container?.visible as TargetAndTransition).transition
            ?.staggerChildren
        : undefined;

    const customDelay = hasTransition(variants?.container?.visible ?? {})
        ? (variants?.container?.visible as TargetAndTransition).transition
            ?.delayChildren
        : undefined;

    const computedVariants = {
        container: createVariantsWithTransition(
            variants?.container || baseVariants.container,
            {
                staggerChildren: customStagger ?? stagger,
                delayChildren: customDelay ?? delay,
                ...containerTransition,
                exit: {
                    staggerChildren: customStagger ?? stagger,
                    staggerDirection: -1,
                },
            }
        ),
        item: createVariantsWithTransition(variants?.item || baseVariants.item, {
            duration: baseDuration,
            ...segmentTransition,
        }),
    };

    return (
        <AnimatePresence mode="popLayout">
            {trigger && (
                <MotionTag
                    initial="hidden"
                    animate="visible"
                    exit="exit"
                    variants={computedVariants.container}
                    className={className}
                    onAnimationComplete={onAnimationComplete}
                    onAnimationStart={onAnimationStart}
                    style={style}
                >
                    {per !== "line" ? <span className="sr-only">{children}</span> : null}
                    {segments.map((segment, index) => (
                        <AnimationComponent
                            key={`${per}-${index}-${segment}`}
                            segment={segment}
                            variants={computedVariants.item}
                            per={per}
                            segmentWrapperClassName={segmentWrapperClassName}
                        />
                    ))}
                </MotionTag>
            )}
        </AnimatePresence>
    );
}
```

### `src/components/ui/animated-group.tsx`

```tsx
// Component From Motion Primitives

"use client";
import { ReactNode } from "react";
import { motion, Variants, HTMLMotionProps } from "motion/react";
import React from "react";

export type PresetType =
  | "fade"
  | "slide"
  | "scale"
  | "blur"
  | "blur-slide"
  | "zoom"
  | "flip"
  | "bounce"
  | "rotate"
  | "swing";

export type AnimatedGroupProps = {
  children: ReactNode;
  className?: string;
  variants?: {
    container?: Variants;
    item?: Variants;
  };
  preset?: PresetType;
  as?: React.ElementType;
  asChild?: React.ElementType;
};

const defaultContainerVariants: Variants = {
  visible: {
    transition: {
      staggerChildren: 0.1,
    },
  },
};

const defaultItemVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1 },
};

const presetVariants: Record<PresetType, Variants> = {
  fade: {},
  slide: {
    hidden: { y: 20 },
    visible: { y: 0 },
  },
  scale: {
    hidden: { scale: 0.8 },
    visible: { scale: 1 },
  },
  blur: {
    hidden: { filter: "blur(4px)" },
    visible: { filter: "blur(0px)" },
  },
  "blur-slide": {
    hidden: { filter: "blur(4px)", y: 20 },
    visible: { filter: "blur(0px)", y: 0 },
  },
  zoom: {
    hidden: { scale: 0.5 },
    visible: {
      scale: 1,
      transition: { type: "spring", stiffness: 300, damping: 20 },
    },
  },
  flip: {
    hidden: { rotateX: -90 },
    visible: {
      rotateX: 0,
      transition: { type: "spring", stiffness: 300, damping: 20 },
    },
  },
  bounce: {
    hidden: { y: -50 },
    visible: {
      y: 0,
      transition: { type: "spring", stiffness: 400, damping: 10 },
    },
  },
  rotate: {
    hidden: { rotate: -180 },
    visible: {
      rotate: 0,
      transition: { type: "spring", stiffness: 200, damping: 15 },
    },
  },
  swing: {
    hidden: { rotate: -10 },
    visible: {
      rotate: 0,
      transition: { type: "spring", stiffness: 300, damping: 8 },
    },
  },
};

const mergeVariants = (
  defaultVariants: Variants,
  customVariants: Partial<Variants> = {},
): Variants => {
  return {
    hidden: {
      ...defaultVariants.hidden,
      ...customVariants.hidden,
    },
    visible: {
      ...defaultVariants.visible,
      ...customVariants.visible,
    },
  };
};

function AnimatedGroup({
  children,
  className,
  variants,
  preset = "fade",
  as = "div",
  asChild = "div",
}: AnimatedGroupProps) {
  const MotionContainer = motion.div;
  const MotionChild = motion.div;

  const presetVariant = preset ? presetVariants[preset] : {};
  const itemVariants = variants?.item
    ? mergeVariants(defaultItemVariants, variants.item)
    : mergeVariants(defaultItemVariants, presetVariant);

  const containerVariants = variants?.container || defaultContainerVariants;

  return (
    <MotionContainer
      initial="hidden"
      animate="visible"
      variants={containerVariants}
      className={className}
    >
      {React.Children.map(children, (child, index) => (
        <MotionChild key={index} variants={itemVariants}>
          {child}
        </MotionChild>
      ))}
    </MotionContainer>
  );
}

export { AnimatedGroup };
```
