solaceui
blockhero-section-1source: ok · hit
Hero Section 1
Solace UI hero section block variation 1.
npx shadcn add "https://www.solaceui.com/r/hero-section-1.json"free
Where it goes
hero
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Registry dependencies
button
Files4 · source cached 10/14/2026, 7:33:17 PM
src/registry/blocks/hero-section/one/index.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
// 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
// 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
// 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 };