Skip to content
ui/registry
Browse families

Tilted Grid Hero

NEW

Live preview

Loading preview…

Installation

Install the dependencies, then copy the source below into your project.

npm install clsx tailwind-merge
import TiltedGridHero from "@/components/ui/TiltedGridHero";

Source

One file. Paths are relative to your source root (the @/ alias).

components/ui/TiltedGridHero.tsx tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

const SLICES = 16;
const CAMERA = 1.6;

function arc(bend: number) {
  return (CAMERA - 1 + Math.cos(bend)) / (2 * CAMERA * Math.sin(bend));
}

const MAX_WIDTH = 55;

function measure(
  width: number,
  height: number,
  tile: number,
  aspect: number,
  gap: number,
  bend: number,
) {
  const h = Math.min(
    (tile / 100) * height,
    ((MAX_WIDTH / 100) * width) / aspect,
  );
  const radius = width * arc(bend);
  if (!(h > 0) || !(radius > 0))
    return { columns: 2, sweep: 0, unit: 0, limit: 0 };
  const deg = (rad: number) => (rad * 180) / Math.PI;
  const unit = deg(h / radius);
  const pitch = (aspect + gap / 100) * unit;
  const limit = deg(bend) + (aspect * unit) / 2;
  const columns = Math.min(60, Math.max(2, Math.ceil((2 * limit) / pitch)));
  const round = (n: number) => +n.toFixed(4);
  const sweep = round((columns * pitch) / 2);
  return {
    columns,
    sweep,
    unit: round(unit),
    limit: round(Math.min(sweep, limit)),
  };
}

export type TiltedGridImage = {
  src: string;
  alt?: string;
};

export type TiltedGridHeroProps = {
  images?: TiltedGridImage[];
  speed?: number;
  tileHeight?: number;
  aspectRatio?: number;
  gap?: number;
  axis?: number;
  curve?: number;
  fade?: number;
  title?: string;
  subtitleTop?: string;
  subtitleBottom?: string;
  primaryBtnText?: string;
  secondaryBtnText?: string;
  onPrimaryClick?: () => void;
  onSecondaryClick?: () => void;
  children?: React.ReactNode;
  className?: string;
};

const DEFAULT_IMAGES: TiltedGridImage[] = [
  { src: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=1200&auto=format&fit=crop&q=80", alt: "Abstract 1" },
  { src: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=1200&auto=format&fit=crop&q=80", alt: "Abstract 2" },
  { src: "https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=1200&auto=format&fit=crop&q=80", alt: "Abstract 3" },
  { src: "https://images.unsplash.com/photo-1635070041078-e363dbe005cb?w=1200&auto=format&fit=crop&q=80", alt: "Abstract 4" },
];

export function TiltedGridHero({
  images = DEFAULT_IMAGES,
  speed = 4,
  tileHeight = 26,
  aspectRatio = 16 / 9,
  gap = 6,
  axis = 52,
  curve = 80,
  fade = 12,
  title = "Esplora la Nuova Dimensione",
  subtitleTop = "DESIGN E TECNOLOGIA",
  subtitleBottom = "Un'esperienza visiva fluida e interattiva progettata per catturare l'attenzione.",
  primaryBtnText = "Inizia Ora",
  secondaryBtnText = "Scopri di più",
  onPrimaryClick,
  onSecondaryClick,
  children,
  className,
  ...props
}: React.ComponentProps<"div"> & TiltedGridHeroProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const id = React.useId().replace(/[^a-zA-Z0-9]/g, "");
  const orbit = `tgh-o-${id}`;
  const tile = `tgh-t-${id}`;
  const bend = (Math.min(85, Math.max(5, curve)) * Math.PI) / 180;

  const [layout, setLayout] = React.useState<ReturnType<typeof measure> | null>(
    null,
  );

  const [shown, setShown] = React.useState<Record<number, number>>({});

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      const next = measure(width, height, tileHeight, aspectRatio, gap, bend);
      setLayout((prev) =>
        prev?.columns === next.columns &&
        prev.sweep === next.sweep &&
        prev.unit === next.unit
          ? prev
          : next,
      );
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [tileHeight, aspectRatio, gap, bend]);

  React.useEffect(() => {
    for (const { src } of images) {
      if (typeof window !== "undefined") {
        const img = new Image();
        img.src = src;
      }
    }
  }, [images]);

  const { columns, sweep, unit, limit } =
    layout ?? measure(1200, 800, tileHeight, aspectRatio, gap, bend);

  const u = (n: number) =>
    `calc(${+n.toFixed(4)} * min(${tileHeight}cqh, ${+(
      MAX_WIDTH / aspectRatio
    ).toFixed(4)}cqw))`;

  const r = 100 * arc(bend);
  const radius = `${+r.toFixed(3)}cqw`;

  const turn = (deg: number) =>
    `translateZ(${radius}) rotateY(${+deg.toFixed(4)}deg) translateZ(-${radius})`;

  const hide = +(((sweep - limit) / (2 * sweep || 1)) * 100).toFixed(4);
  const css =
    `@keyframes ${orbit}{` +
    `from{transform:${turn(-sweep)}}to{transform:${turn(sweep)}}` +
    `0%,${hide}%,${100 - hide}%,100%{visibility:hidden}` +
    `${hide + 0.001}%,${100 - hide - 0.001}%{visibility:visible}}` +
    `@media(prefers-reduced-motion:reduce){.${tile}{animation-play-state:paused}}`;

  const share = aspectRatio / SLICES;
  const duration = columns * speed;
  const mask = `linear-gradient(90deg,transparent,#000 ${fade}%,#000 ${
    100 - fade
  }%,transparent)`;

  return (
    <div
      ref={ref}
      className={cn(
        // Utilizziamo 'bg-background' e 'text-foreground' in modo che risponda a Dark, Light e Transparent
        "relative flex min-h-screen h-[100dvh] w-full flex-col justify-between overflow-hidden bg-background p-6 text-foreground sm:p-12",
        className
      )}
      {...props}
      style={{ containerType: "size", ...props.style }}
    >
      <style>{css}</style>

      {/* ── BANDA 3D ORIGINALE ── */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 transition-opacity duration-500"
        style={{
          opacity: layout ? 1 : 0,
          perspective: `${+(r * CAMERA).toFixed(3)}cqw`,
          perspectiveOrigin: `50% ${axis}%`,
          maskImage: mask,
          WebkitMaskImage: mask,
        }}
      >
        {Array.from({ length: columns }, (_, t) => {
          const first = columns - 1 - t;
          const img = images[(shown[t] ?? first) % Math.max(images.length, 1)];
          return (
            <div
              key={t}
              className={cn(tile, "absolute")}
              style={{
                left: `calc(50% - ${u(aspectRatio / 2)})`,
                top: `calc(${axis}% - ${u(0.5)})`,
                width: u(aspectRatio),
                height: u(1),
                transformStyle: "preserve-3d",
                animation: `${orbit} ${duration}s linear ${-t * speed}s infinite`,
              }}
              onAnimationIteration={(e) => {
                const lap = Math.round(e.elapsedTime / duration);
                setShown((prev) => {
                  const next = lap * columns + first;
                  return prev[t] === next ? prev : { ...prev, [t]: next };
                });
              }}
            >
              {Array.from({ length: SLICES }, (_, k) => (
                <div
                  key={k}
                  className={cn(
                    "absolute top-0 overflow-hidden",
                    k === 0 && "rounded-l-lg",
                    k === SLICES - 1 && "rounded-r-lg"
                  )}
                  style={{
                    left: u((aspectRatio - share) / 2),
                    width:
                      k === SLICES - 1 ? u(share) : `calc(${u(share)} + 1px)`,
                    height: u(1),
                    transform: turn(
                      (aspectRatio / 2 - (k + 0.5) * share) * unit
                    ),
                    backfaceVisibility: "hidden",
                    WebkitBackfaceVisibility: "hidden",
                  }}
                >
                  {img ? (
                    <img
                      src={img.src}
                      alt={k === 0 ? (img.alt ?? "") : ""}
                      draggable={false}
                      className="absolute top-0 max-w-none object-cover"
                      style={{
                        left: u(-k * share),
                        width: u(aspectRatio),
                        height: u(1),
                        backfaceVisibility: "hidden",
                        WebkitBackfaceVisibility: "hidden",
                        willChange: "transform",
                        transform: "translateZ(0)",
                      }}
                    />
                  ) : null}
                </div>
              ))}
            </div>
          );
        })}
      </div>

      {/* ── VIGNETTATURA ADATTABILE TEMA (from-background to-background) ── */}
      <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-background/90 via-transparent to-background/90" />

      {/* ── OVERLAY CON TESTI E BOTTONI SEMANTICI ── */}
      {children ? (
        <div className="relative z-10 flex h-full items-center justify-center">
          {children}
        </div>
      ) : (
        <>
          {/* Titolo e frase SOPRA */}
          <div className="relative z-10 flex flex-col items-center text-center pt-4">
            <span className="mb-3 inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-4 py-1.5 text-xs font-semibold tracking-wider text-primary uppercase backdrop-blur-md">
              {subtitleTop}
            </span>
            <h1 className="max-w-4xl text-4xl font-extrabold tracking-tight text-foreground drop-shadow-lg sm:text-6xl lg:text-7xl">
              {title}
            </h1>
          </div>

          {/* Frase e bottoni SOTTO */}
          <div className="relative z-10 flex flex-col items-center text-center pb-4">
            <p className="mb-6 max-w-xl text-base text-muted-foreground drop-shadow sm:text-lg">
              {subtitleBottom}
            </p>

            <div className="flex flex-wrap items-center justify-center gap-4">
              <button
                onClick={onPrimaryClick}
                className="rounded-xl bg-primary px-7 py-3.5 text-base font-semibold text-primary-foreground shadow-xl transition-all hover:opacity-90 active:scale-95"
              >
                {primaryBtnText}
              </button>
              <button
                onClick={onSecondaryClick}
                className="rounded-xl border border-border bg-secondary/80 px-7 py-3.5 text-base font-semibold text-secondary-foreground backdrop-blur-md transition-all hover:bg-secondary active:scale-95"
              >
                {secondaryBtnText}
              </button>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

export default TiltedGridHero;