Tilted Grid Hero
NEWLive preview
Loading preview…
Installation
Install the dependencies, then copy the source below into your project.
npm install clsx tailwind-mergeimport TiltedGridHero from "@/components/ui/TiltedGridHero"; Source
One file. Paths are relative to your source root (the @/ alias).
"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;