Section
Setby Hero
A logistics landing hero: split-text headings, a rotating badge, and a WebGL depth-map image that parallaxes against the cursor.
Mission
At Setby, we're on a mission to bring life into every space — just like sunlight breaking through a window.
What is Setby
GuaranteedSecurity
On TimeDelivery
24/7Support
Real TimeTracking
Precision
500+
Deliveries (mostly on time)
50+
Cities served
99%
Packages that arrive intact
Delivery
import SetbyHero from "@/components/ui/setby-hero";
export default function SetbyHeroDemo({ fullscreen }: { fullscreen?: boolean }) {
return (
<div className="w-full min-w-0">
<SetbyHero height={fullscreen ? "100vh" : "660px"} />
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap @gsap/react lucide-react clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/setby-hero.tsx "use client"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ArrowRight, Menu } from "lucide-react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; import DepthImage from "@/components/ui/setby/depth-image"; import SplitText from "@/components/ui/setby/split-text"; import RotatingBadge from "@/components/ui/setby/rotating-badge"; import Stats from "@/components/ui/setby/stats"; import MissionBar from "@/components/ui/setby/mission-bar"; import SpecBadge from "@/components/ui/setby/spec-badge"; const LOADER_DELAY = 0; const HEADER_DURATION = 1; const IMAGE_DURATION = 1; const TEXT_DURATION = 1; const TEXT_CHAR_STAGGER = 0.05; const LOADER_EASE = "sine.inOut"; // Dispatched by the page's <StairsPreloader> at 70% of its run — see // templates/preview/setby.astro. Standalone usages (e.g. the component doc // page) have no preloader to dispatch it, so a short fallback kicks in. const HERO_READY_EVENT = "setby:hero-ready"; const HERO_READY_FALLBACK_MS = 400; // On the very first scroll, the hero's foreground content vanishes off the // top of the section in one shot (not tied to scroll distance) — the // per-element stagger below is what makes it read as a cascade rather than // everything snapping away in lockstep. Scrolling back to the top reverses it. const DISAPPEAR_DISTANCE = 320; const DISAPPEAR_STAGGER_MS = 60; const links = [ { label: "About Us", href: "#about" }, { label: "Services", href: "#services" }, { label: "FAQ", href: "#faq" }, { label: "Reviews", href: "#reviews" }, ]; export interface SetbyHeroHandle { /** 0 = white container fully shown, 1 = fully faded to reveal the orange one underneath. */ setContainerFade: (progress: number) => void; } const SetbyHero = forwardRef<SetbyHeroHandle, { height?: string }>(function SetbyHero( { height = "100vh" }, ref ) { const sectionRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null); const imageRef = useRef<HTMLCanvasElement>(null); const orangeImageRef = useRef<HTMLCanvasElement>(null); const sinceRef = useRef<HTMLParagraphElement>(null); const badgeRef = useRef<HTMLDivElement>(null); const ctaRef = useRef<HTMLAnchorElement>(null); const statsRef = useRef<HTMLDivElement>(null); const precisionRef = useRef<HTMLDivElement>(null); const deliveryRef = useRef<HTMLDivElement>(null); const missionRef = useRef<HTMLDivElement>(null); const secondStateRef = useRef<HTMLDivElement>(null); const [scrolled, setScrolled] = useState(false); useImperativeHandle( ref, () => ({ setContainerFade(progress: number) { const clamped = Math.min(Math.max(progress, 0), 1); if (imageRef.current) imageRef.current.style.opacity = String(1 - clamped); if (orangeImageRef.current) orangeImageRef.current.style.opacity = String(clamped); // Second hero state (orange container): mission row + spec badges. if (missionRef.current) { missionRef.current.style.opacity = String(clamped); missionRef.current.style.transform = `translateY(${(1 - clamped) * 24}px)`; } if (secondStateRef.current) secondStateRef.current.style.opacity = String(clamped); }, }), [] ); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 10); onScroll(); // A reload that lands mid-scroll can have the browser restore scroll // position a frame (or more) after mount, with no guaranteed "scroll" // event in between — re-check once more so this doesn't get stuck // showing first-state content while the page is really in state two. const raf = requestAnimationFrame(onScroll); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("pageshow", onScroll); return () => { cancelAnimationFrame(raf); window.removeEventListener("scroll", onScroll); window.removeEventListener("pageshow", onScroll); }; }, []); useEffect(() => { const y = scrolled ? -DISAPPEAR_DISTANCE : 0; // Bottom/right-most elements go first, so it reads as a cascade up and // off-screen. Stats keeps its own -50% centering transform as a base — // the disappear offset is layered on top rather than replacing it. const order: Array<{ el: HTMLElement | null; base?: string }> = [ { el: deliveryRef.current }, { el: statsRef.current, base: "translateY(-50%)" }, { el: ctaRef.current }, { el: badgeRef.current }, { el: sinceRef.current }, { el: precisionRef.current }, ]; order.forEach(({ el, base }, index) => { if (!el) return; el.style.transitionDelay = `${index * DISAPPEAR_STAGGER_MS}ms`; el.style.transform = base ? `${base} translateY(${y}px)` : `translateY(${y}px)`; el.style.opacity = scrolled ? "0" : "1"; }); }, [scrolled]); const imageEntranceReadyRef = useRef({ hero: false, image: false }); const tryPlayImageEntrance = () => { if (!imageEntranceReadyRef.current.hero || !imageEntranceReadyRef.current.image) return; gsap.to(imageRef.current, { y: "-4rem", duration: IMAGE_DURATION, ease: LOADER_EASE, delay: LOADER_DELAY / 1000, }); }; const playImageEntrance = () => { imageEntranceReadyRef.current.image = true; tryPlayImageEntrance(); }; useEffect(() => { // Hidden immediately on mount so nothing flashes visible before the // ready cue arrives — only the *reveal* is deferred, not the hiding. gsap.set(headerRef.current, { y: "-150%" }); gsap.set(sinceRef.current, { y: 10, opacity: 0 }); gsap.set(badgeRef.current, { scale: 0.6, opacity: 0 }); gsap.set(ctaRef.current, { y: 10, opacity: 0 }); gsap.set(statsRef.current ? Array.from(statsRef.current.children) : [], { y: 10, opacity: 0 }); let ctx: gsap.Context | undefined; let played = false; const play = () => { if (played) return; played = true; imageEntranceReadyRef.current.hero = true; tryPlayImageEntrance(); ctx = gsap.context(() => { gsap.to(headerRef.current, { y: 0, duration: HEADER_DURATION, ease: LOADER_EASE }); gsap.to(sinceRef.current, { y: 0, opacity: 1, duration: TEXT_DURATION, ease: LOADER_EASE }); gsap.to(badgeRef.current, { scale: 1, opacity: 1, duration: TEXT_DURATION, ease: LOADER_EASE }); gsap.to(ctaRef.current, { y: 0, opacity: 1, duration: TEXT_DURATION, ease: LOADER_EASE }); gsap.to(statsRef.current ? Array.from(statsRef.current.children) : [], { y: 0, opacity: 1, duration: TEXT_DURATION, ease: LOADER_EASE, stagger: 0.1, }); }); }; window.addEventListener(HERO_READY_EVENT, play, { once: true }); const fallback = window.setTimeout(play, HERO_READY_FALLBACK_MS); return () => { window.removeEventListener(HERO_READY_EVENT, play); window.clearTimeout(fallback); ctx?.revert(); }; }, []); return ( <section ref={sectionRef} style={{ height }} className="relative w-full overflow-hidden bg-white" > <header ref={headerRef} className="relative z-30 w-[92%] max-w-7xl mx-auto pt-6 pb-2 flex items-center justify-between -translate-y-[150%] font-[family-name:var(--font-chivo-mono)]" > <img src="/setby/logo.svg" alt="Setby Logistics" width={120} height={32} className="h-7 sm:h-8 w-auto" /> <div className="relative flex items-center h-10 sm:h-11"> <div className={cn( "flex items-center gap-6 sm:gap-8 transition-all duration-500 ease-out", scrolled ? "translate-x-4 opacity-0 pointer-events-none" : "translate-x-0 opacity-100" )} > <nav className="hidden md:flex items-center gap-8"> {links.map((link) => ( <a key={link.href} href={link.href} className="text-sm sm:text-base font-medium text-black hover:opacity-70 transition-opacity" > {link.label} </a> ))} </nav> <a href="#contact" className="h-10 sm:h-11 flex items-center px-5 sm:px-6 bg-black text-white text-sm sm:text-base font-medium hover:bg-black/80 transition-colors" > Contact </a> </div> <button type="button" aria-label="Open menu" className={cn( "absolute right-0 top-1/2 flex h-10 w-10 sm:h-11 sm:w-11 -translate-y-1/2 items-center justify-center bg-black text-white transition-all duration-500 ease-out", scrolled ? "scale-100 opacity-100" : "pointer-events-none scale-75 opacity-0" )} > <Menu className="h-4 w-4 sm:h-5 sm:w-5" /> </button> </div> </header> <div ref={missionRef} className="relative z-30 mt-4 w-full transition-[opacity,transform] duration-700 ease-out will-change-transform sm:mt-6" style={{ opacity: 0, transform: "translateY(24px)" }} > <MissionBar /> </div> {/* Second hero state (orange container) content — hidden during the first state (white container) and faded in together with it. */} <div ref={secondStateRef} className="absolute inset-0 z-20 transition-opacity duration-700 ease-out" style={{ opacity: 0 }} > <span className="absolute whitespace-nowrap font-[family-name:var(--font-chivo-mono)] text-xs uppercase tracking-[0.2em] text-gray-400 sm:text-sm" style={{ left: "37.3%", top: "10.5%" }} > What is Setby </span> <SpecBadge lines={["Guaranteed", "Security"]} left="34.0%" top="27.4%" direction="up-left" /> <SpecBadge lines={["On Time", "Delivery"]} left="71.1%" top="32.8%" direction="up-right" /> <SpecBadge lines={["24/7", "Support"]} left="57.6%" top="66.9%" direction="down" /> <SpecBadge lines={["Real Time", "Tracking"]} left="29.1%" top="67.8%" direction="up-left" /> </div> <div ref={precisionRef} className="absolute top-20 sm:top-24 left-[5%] sm:left-[8%] z-0 transition-[opacity,transform] duration-700 ease-out will-change-transform" > <SplitText text="Precision" tag="p" textAlign="left" className="text-[clamp(3.5rem,9vw,10.5rem)] leading-none font-extrabold text-black font-[family-name:var(--font-plus-jakarta-sans)] select-none whitespace-nowrap" splitType="chars" from={{ y: 20, opacity: 0.2 }} to={{ y: 0, opacity: 1 }} duration={TEXT_DURATION} ease={LOADER_EASE} delay={TEXT_CHAR_STAGGER * 1000} staggerFrom={3} threshold={0} rootMargin="0px" /> </div> <div className="absolute top-[38%] left-[5%] sm:left-[8%] z-10 flex flex-col gap-6 sm:gap-8"> <p ref={sinceRef} className="text-xs sm:text-sm text-gray-400 font-[family-name:var(--font-chivo-mono)] transition-[opacity,transform] duration-700 ease-out will-change-transform" > //since 2020 </p> <div ref={badgeRef} className="transition-[opacity,transform] duration-700 ease-out will-change-transform"> <RotatingBadge /> </div> <a ref={ctaRef} href="#contact" className="inline-flex items-center gap-2 w-fit px-5 sm:px-6 py-2.5 sm:py-3 bg-[#FF5C32] text-white text-sm sm:text-base font-medium hover:bg-[#FF5C32]/90 shadow-sm transition-[background-color,opacity,transform] duration-500 ease-out will-change-transform" > Lets get your cargo <ArrowRight className="w-4 h-4" /> </a> </div> <Stats ref={statsRef} className="transition-[opacity,transform] duration-700 ease-out will-change-transform" /> {/* Same framing and depth parallax as the white container, sitting directly behind it — hidden until the scroll-driven fade reveals it. */} <DepthImage ref={orangeImageRef} containerRef={sectionRef} colorSrc="/setby/orange-cn.png" depthSrc="/setby/white-con-depth.png" strength={0.015} className="absolute inset-0 w-full h-full z-10" style={{ transform: "translateY(-4rem)", opacity: 0 }} /> <DepthImage ref={imageRef} containerRef={sectionRef} colorSrc="/setby/white-con.png" depthSrc="/setby/white-con-depth.png" strength={0.015} className="absolute inset-0 w-full h-full z-10" style={{ transform: "translateY(-90%)" }} onReady={playImageEntrance} /> <div ref={deliveryRef} className="absolute bottom-16 sm:bottom-24 right-[5%] sm:right-[8%] z-20 transition-[opacity,transform] duration-700 ease-out will-change-transform" > <SplitText text="Delivery" tag="p" textAlign="left" className="text-[clamp(3.5rem,9vw,10.5rem)] leading-none font-extrabold text-black font-[family-name:var(--font-plus-jakarta-sans)] select-none whitespace-nowrap" splitType="chars" from={{ y: 20, opacity: 0.2 }} to={{ y: 0, opacity: 1 }} duration={TEXT_DURATION} ease={LOADER_EASE} delay={TEXT_CHAR_STAGGER * 1000} staggerFrom={3} threshold={0} rootMargin="0px" /> </div> </section> ); }); export default SetbyHero;components/ui/setby/depth-image.tsx "use client"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; const VERTEX_SHADER = `#version 300 es in vec2 aPosition; void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`; const FRAGMENT_SHADER = `#version 300 es precision highp float; out vec4 outColor; uniform sampler2D uColor; uniform sampler2D uDepth; uniform vec2 uMouse; uniform float uStrength; uniform vec2 uCanvasSize; uniform vec2 uImageSize; uniform float uScale; uniform vec2 uOffsetPx; uniform float uDepthBlur; void main() { vec2 fragPx = vec2(gl_FragCoord.x, uCanvasSize.y - gl_FragCoord.y); vec2 imgPx = (fragPx - uOffsetPx) / uScale; vec2 baseUv = imgPx / uImageSize; if (baseUv.x < 0.0 || baseUv.x > 1.0 || baseUv.y < 0.0 || baseUv.y > 1.0) { outColor = vec4(0.0); return; } vec2 texUv = vec2(baseUv.x, 1.0 - baseUv.y); float depth = textureLod(uDepth, texUv, uDepthBlur).r; vec2 offset = uMouse * uStrength * (depth - 0.5); vec2 sampledUv = clamp(texUv + vec2(offset.x, -offset.y), 0.0, 1.0); vec4 baseColor = texture(uColor, texUv); outColor = mix(baseColor, texture(uColor, sampledUv), baseColor.a); }`; function loadImage(src: string): Promise<HTMLImageElement> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } function createShader(gl: WebGL2RenderingContext, type: number, source: string) { const shader = gl.createShader(type)!; gl.shaderSource(shader, source); gl.compileShader(shader); return shader; } function createTexture( gl: WebGL2RenderingContext, image: HTMLImageElement, withMipmaps = false ) { const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); if (withMipmaps) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.generateMipmap(gl.TEXTURE_2D); } else { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); } return texture; } type Props = { colorSrc: string; depthSrc: string; containerRef: React.RefObject<HTMLElement | null>; strength?: number; className?: string; style?: React.CSSProperties; onReady?: () => void; }; const DepthImage = forwardRef<HTMLCanvasElement, Props>(function DepthImage( { colorSrc, depthSrc, containerRef, strength = 0.02, className, style, onReady }, forwardedRef ) { const canvasRef = useRef<HTMLCanvasElement>(null); useImperativeHandle(forwardedRef, () => canvasRef.current as HTMLCanvasElement); const mouseTarget = useRef({ x: 0, y: 0 }); const mouseCurrent = useRef({ x: 0, y: 0 }); const imageSize = useRef({ w: 1, h: 1 }); const onReadyRef = useRef(onReady); onReadyRef.current = onReady; useEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const gl = canvas.getContext("webgl2"); if (!gl) return; let raf = 0; let destroyed = false; const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); const program = gl.createProgram()!; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); gl.useProgram(program); const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW ); const aPosition = gl.getAttribLocation(program, "aPosition"); gl.enableVertexAttribArray(aPosition); gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); const uMouse = gl.getUniformLocation(program, "uMouse"); const uStrength = gl.getUniformLocation(program, "uStrength"); const uCanvasSize = gl.getUniformLocation(program, "uCanvasSize"); const uImageSize = gl.getUniformLocation(program, "uImageSize"); const uScale = gl.getUniformLocation(program, "uScale"); const uOffsetPx = gl.getUniformLocation(program, "uOffsetPx"); const uDepthBlur = gl.getUniformLocation(program, "uDepthBlur"); const uColor = gl.getUniformLocation(program, "uColor"); const uDepth = gl.getUniformLocation(program, "uDepth"); let colorTexture: WebGLTexture | null = null; let depthTexture: WebGLTexture | null = null; const resize = () => { const rect = container.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = `${rect.width}px`; canvas.style.height = `${rect.height}px`; gl.viewport(0, 0, canvas.width, canvas.height); }; const handleMouseMove = (e: MouseEvent) => { const rect = container.getBoundingClientRect(); mouseTarget.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouseTarget.current.y = ((e.clientY - rect.top) / rect.height) * 2 - 1; }; const render = () => { if (destroyed) return; mouseCurrent.current.x += (mouseTarget.current.x - mouseCurrent.current.x) * 0.08; mouseCurrent.current.y += (mouseTarget.current.y - mouseCurrent.current.y) * 0.08; const { w: imgW, h: imgH } = imageSize.current; const scale = Math.min(canvas.width / imgW, canvas.height / imgH); const offsetX = (canvas.width - imgW * scale) / 2; const offsetY = 0; gl.uniform2f(uMouse, mouseCurrent.current.x, mouseCurrent.current.y); gl.uniform1f(uStrength, strength); gl.uniform2f(uCanvasSize, canvas.width, canvas.height); gl.uniform2f(uImageSize, imgW, imgH); gl.uniform1f(uScale, scale); gl.uniform2f(uOffsetPx, offsetX, offsetY); gl.uniform1f(uDepthBlur, 2.5); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); raf = requestAnimationFrame(render); }; Promise.all([loadImage(colorSrc), loadImage(depthSrc)]).then(([colorImg, depthImg]) => { if (destroyed) return; imageSize.current = { w: colorImg.naturalWidth, h: colorImg.naturalHeight }; colorTexture = createTexture(gl, colorImg); depthTexture = createTexture(gl, depthImg, true); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, colorTexture); gl.uniform1i(uColor, 0); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, depthTexture); gl.uniform1i(uDepth, 1); resize(); render(); onReadyRef.current?.(); }); window.addEventListener("resize", resize); container.addEventListener("mousemove", handleMouseMove); return () => { destroyed = true; cancelAnimationFrame(raf); window.removeEventListener("resize", resize); container.removeEventListener("mousemove", handleMouseMove); gl.deleteTexture(colorTexture); gl.deleteTexture(depthTexture); gl.deleteProgram(program); gl.deleteBuffer(positionBuffer); }; }, [colorSrc, depthSrc, containerRef, strength]); return <canvas ref={canvasRef} className={className} style={style} />; }); export default DepthImage;components/ui/setby/split-text.tsx "use client"; import React, { useRef, useEffect, useState } from 'react'; import { gsap } from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { SplitText as GSAPSplitText } from 'gsap/SplitText'; import { useGSAP } from '@gsap/react'; gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP); export interface SplitTextProps { text: string; className?: string; delay?: number; duration?: number; ease?: string | ((t: number) => number); splitType?: 'chars' | 'words' | 'lines' | 'words, chars'; from?: gsap.TweenVars; to?: gsap.TweenVars; threshold?: number; rootMargin?: string; tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span'; textAlign?: React.CSSProperties['textAlign']; onLetterAnimationComplete?: () => void; staggerFrom?: number | 'start' | 'center' | 'end' | 'edges' | 'random'; } const SplitText: React.FC<SplitTextProps> = ({ text, className = '', delay = 50, duration = 1.25, ease = 'power3.out', splitType = 'chars', from = { opacity: 0, y: 40 }, to = { opacity: 1, y: 0 }, threshold = 0.1, rootMargin = '-100px', tag = 'p', textAlign = 'center', onLetterAnimationComplete, staggerFrom = 0 }) => { const ref = useRef<HTMLParagraphElement>(null); const animationCompletedRef = useRef(false); const onCompleteRef = useRef(onLetterAnimationComplete); const [fontsLoaded, setFontsLoaded] = useState<boolean>(false); // Keep callback ref updated useEffect(() => { onCompleteRef.current = onLetterAnimationComplete; }, [onLetterAnimationComplete]); useEffect(() => { if (document.fonts.status === 'loaded') { setFontsLoaded(true); } else { document.fonts.ready.then(() => { setFontsLoaded(true); }); } }, []); useGSAP( () => { if (!ref.current || !text || !fontsLoaded) return; // Prevent re-animation if already completed if (animationCompletedRef.current) return; const el = ref.current as HTMLElement & { _rbsplitInstance?: GSAPSplitText; }; if (el._rbsplitInstance) { try { el._rbsplitInstance.revert(); } catch (_) {} el._rbsplitInstance = undefined; } const startPct = (1 - threshold) * 100; const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin); const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0; const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px'; const sign = marginValue === 0 ? '' : marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`; const start = `top ${startPct}%${sign}`; let targets: Element[] = []; const assignTargets = (self: GSAPSplitText) => { if (splitType.includes('chars') && (self as GSAPSplitText).chars?.length) targets = (self as GSAPSplitText).chars; if (!targets.length && splitType.includes('words') && self.words.length) targets = self.words; if (!targets.length && splitType.includes('lines') && self.lines.length) targets = self.lines; if (!targets.length) targets = self.chars || self.words || self.lines; }; const splitInstance = new GSAPSplitText(el, { type: splitType, smartWrap: true, autoSplit: splitType === 'lines', linesClass: 'split-line', wordsClass: 'split-word', charsClass: 'split-char', reduceWhiteSpace: false, onSplit: (self: GSAPSplitText) => { assignTargets(self); return gsap.fromTo( targets, { ...from }, { ...to, duration, ease, stagger: { each: delay / 1000, from: staggerFrom }, scrollTrigger: { trigger: el, start, once: true, fastScrollEnd: true, anticipatePin: 0.4 }, onComplete: () => { animationCompletedRef.current = true; onCompleteRef.current?.(); }, willChange: 'transform, opacity', force3D: true } ); } }); el._rbsplitInstance = splitInstance; return () => { ScrollTrigger.getAll().forEach(st => { if (st.trigger === el) st.kill(); }); try { splitInstance.revert(); } catch (_) {} el._rbsplitInstance = undefined; }; }, { dependencies: [ text, delay, duration, ease, splitType, JSON.stringify(from), JSON.stringify(to), threshold, rootMargin, fontsLoaded, staggerFrom ], scope: ref } ); const renderTag = () => { const style: React.CSSProperties = { textAlign, wordWrap: 'break-word', willChange: 'transform, opacity' }; const classes = `split-parent overflow-hidden inline-block whitespace-normal ${className}`; // Typed with concrete props rather than a bare ElementType: R3F augments // the JSX namespace, and a dynamic element type resolves its props to // `never` against the widened namespace. const Tag = (tag || 'p') as React.ElementType<React.ComponentPropsWithRef<'p'>>; return ( <Tag ref={ref} style={style} className={classes}> {text} </Tag> ); }; return renderTag(); }; export default SplitText;components/ui/setby/rotating-badge.tsx "use client"; import { useEffect, useRef } from "react"; import { Play } from "lucide-react"; import gsap from "gsap"; const BADGE_TEXT = "SETBY LOGISTICS • SETBY LOGISTICS • "; export default function RotatingBadge() { const ringRef = useRef<SVGSVGElement>(null); useEffect(() => { const ctx = gsap.context(() => { gsap.to(ringRef.current, { rotate: 360, duration: 12, ease: "none", repeat: -1, transformOrigin: "50% 50%", }); }); return () => ctx.revert(); }, []); return ( <div className="relative w-[150px] h-[150px]"> <svg ref={ringRef} viewBox="0 0 150 150" className="w-full h-full"> <defs> <path id="badge-circle" d="M 75,75 m -60,0 a 60,60 0 1,1 120,0 a 60,60 0 1,1 -120,0" /> </defs> <text className="fill-black text-[11px] tracking-[0.2em] font-[family-name:var(--font-chivo-mono)] uppercase"> <textPath href="#badge-circle">{BADGE_TEXT}</textPath> </text> </svg> <div className="absolute inset-0 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-black flex items-center justify-center"> <Play className="w-4 h-4 text-white fill-white" /> </div> </div> </div> ); }components/ui/setby/stats.tsx "use client"; import { forwardRef } from "react"; import { cn } from "@/lib/utils"; const stats = [ { value: "500+", label: "Deliveries (mostly on time)" }, { value: "50+", label: "Cities served" }, { value: "99%", label: "Packages that arrive intact" }, ]; const Stats = forwardRef<HTMLDivElement, { className?: string }>(function Stats({ className }, ref) { return ( <div ref={ref} className={cn( "absolute top-1/2 -translate-y-1/2 right-[5%] z-20 flex flex-col gap-8 text-right font-[family-name:var(--font-chivo-mono)]", className )} > {stats.map((stat) => ( <div key={stat.label}> <p className="text-4xl font-extrabold text-black">{stat.value}</p> <p className="text-sm text-gray-400">{stat.label}</p> </div> ))} </div> ); }); export default Stats;
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height | string | "100vh" | Height of the section. |