GSAP
Animate layouts built with Vortex components using GSAP and its React hook, useGSAP.
bun add gsap @gsap/reactTarget components
- Pass a ref through
attributes, such asattributes={{ ref: container }}, to components that don't acceptrefdirectly. - Vortex class names are generated, so add your own
classNameto elements you animate. - Set the container as the
useGSAPscope. Selectors then stay inside the component, and animations clean up on unmount.
Animate a dashboard card
When the card scrolls into view, the bars grow with a stagger and each digit rolls up from 0 on its own reel with a short blur. Switching periods rolls the digits to the new values and resizes the bars. Bars scale instead of changing height, so the animation never triggers layout:
"use client";
import { useGSAP } from "@gsap/react";
import { Badge, Card, Grid, Stack, Tabs, Text } from "@vortexlabs/vortex";
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import React from "react";
gsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);
// --vortex-motion-ease-out
CustomEase.create("vortex-ease-out", "0.19, 0.91, 0.38, 1");
const periods = {
week: { amount: 48290, change: 12, bars: [32, 54, 41, 68, 57, 82, 100] },
last: { amount: 43170, change: 18, bars: [45, 38, 60, 52, 71, 64, 88] },
earlier: { amount: 36540, change: 24, bars: [28, 46, 35, 58, 49, 62, 74] },
};
type Period = keyof typeof periods;
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const digits = Array.from({ length: 10 }, (_, digit) => digit);
const srOnly: React.CSSProperties = { position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap" };
const reduceMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const reelPosition = (reel: HTMLElement) => -Number(reel.dataset.digit) * 10;
/** Renders each digit on its own reel so GSAP can roll it to a new value. */
const RollingNumber = (props: { value: string }) => {
const { value } = props;
// GSAP owns the reel position after the first render.
const [initialValue] = React.useState(value);
return (
<>
<span style={srOnly}>{value}</span>
<span aria-hidden="true" style={{ display: "inline-flex", fontVariantNumeric: "tabular-nums" }}>
{[...value].map((char, index) =>
/\d/.test(char) ? (
<span key={index} className="slot" style={{ display: "inline-block", height: "1lh", overflow: "hidden" }}>
<span
className="reel"
data-digit={char}
style={{ display: "block", transform: `translateY(-${Number(initialValue[index]) * 10}%)` }}
>
{digits.map((digit) => (
<span key={digit} style={{ display: "block" }}>
{digit}
</span>
))}
</span>
</span>
) : (
<span key={index}>{char}</span>
),
)}
</span>
</>
);
};
const RevenueCard = () => {
const container = React.useRef<HTMLDivElement>(null);
const shownPeriod = React.useRef<Period>("week");
const [period, setPeriod] = React.useState<Period>("week");
const [initialBars] = React.useState(periods.week.bars);
const data = periods[period];
// Intro: bars grow and digits roll up from 0 when the card scrolls into view.
useGSAP(
() => {
const intro = gsap
.timeline({ scrollTrigger: { trigger: container.current, start: "top 85%" } })
.from(".bar", { scaleY: 0, duration: 0.8, stagger: 0.06, ease: "vortex-ease-out" })
.fromTo(".reel", { y: 0, yPercent: 0 }, { yPercent: (_, reel) => reelPosition(reel), duration: 1.1, stagger: 0.08, ease: "power3.out" }, 0)
.to(".slot", { filter: "blur(3px)", duration: 0.15, stagger: 0.08 }, 0)
.to(".slot", { filter: "blur(0px)", duration: 0.8, stagger: 0.08, ease: "power2.out" }, 0.15);
if (reduceMotion()) intro.progress(1);
},
{ scope: container },
);
// Period change: roll each digit and resize each bar to the new values.
useGSAP(
() => {
if (shownPeriod.current === period) return;
shownPeriod.current = period;
const duration = reduceMotion() ? 0 : 0.6;
gsap.to(".reel", { yPercent: (_, reel) => reelPosition(reel), duration, stagger: 0.04, ease: "power3.out" });
gsap
.timeline()
.to(".slot", { filter: duration ? "blur(3px)" : "blur(0px)", duration: duration / 4, stagger: 0.04 })
.to(".slot", { filter: "blur(0px)", duration, stagger: 0.04, ease: "power2.out" }, duration / 4);
gsap.to(".bar", { scaleY: (index) => (data.bars[index] ?? 0) / 100, duration, stagger: 0.03, ease: "vortex-ease-out" });
},
{ dependencies: [period], scope: container },
);
return (
<Stack gap={4} align="center" attributes={{ ref: container }}>
<Tabs variant="segment" value={period} onChange={({ value }) => setPeriod(value as Period)}>
<Tabs.List>
<Tabs.Item value="week">This week</Tabs.Item>
<Tabs.Item value="last">Last week</Tabs.Item>
<Tabs.Item value="earlier">2 weeks ago</Tabs.Item>
</Tabs.List>
</Tabs>
<Card padding="md" width="320px">
<Stack gap={4}>
<Stack direction="row" justify="space-between" align="center">
<Text color="subtle">Revenue</Text>
<Badge tone="success">
<RollingNumber value={`+${data.change}%`} />
</Badge>
</Stack>
<Text as="p" size="heading-md-desktop" weight="medium">
<RollingNumber value={currency.format(data.amount)} />
</Text>
<Grid display="grid" columns={initialBars.length} rows={1} gap={2} height="80px" attributes={{ "aria-hidden": true }}>
{initialBars.map((value, index) => (
<Stack
key={index}
className="bar"
height="100%"
borderRadius="sm"
backgroundColor={index === initialBars.length - 1 ? "primary" : "surface-bold"}
attributes={{ style: { transform: `scaleY(${value / 100})`, transformOrigin: "50% 100%" } }}
/>
))}
</Grid>
</Stack>
</Card>
</Stack>
);
};
export default RevenueCard;Timelines
Chain animations with gsap.timeline(). This like button pops the heart, bursts particles in Vortex colors, and rolls the count. Vortex Button clips its content, so the particles sit next to it rather than inside:
"use client";
import { useGSAP } from "@gsap/react";
import { Button, Stack, Text } from "@vortexlabs/vortex";
import gsap from "gsap";
import React from "react";
gsap.registerPlugin(useGSAP);
const particles = ["critical", "warning", "primary", "success", "info", "critical", "warning", "primary"];
const angle = (index: number) => (index / particles.length) * Math.PI * 2;
const LikeButton = () => {
const container = React.useRef<HTMLDivElement>(null);
const [liked, setLiked] = React.useState(false);
const { contextSafe } = useGSAP({ scope: container });
const toggleLike = contextSafe(() => {
setLiked(!liked);
if (liked || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
gsap
.timeline()
.fromTo(".heart", { scale: 0.3 }, { scale: 1, duration: 0.8, ease: "elastic.out(1, 0.35)" })
.fromTo(
".particle",
{
x: (index) => Math.cos(angle(index)) * 12,
y: (index) => Math.sin(angle(index)) * 12,
scale: 1.4,
opacity: 1,
},
{
x: (index) => Math.cos(angle(index)) * 40,
y: (index) => Math.sin(angle(index)) * 40,
scale: 0,
opacity: 0,
duration: 0.7,
ease: "power3.out",
},
0,
)
.fromTo(".like-count", { yPercent: 100, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.3 }, 0.1);
});
return (
<Stack direction="row" gap={3} align="center" attributes={{ ref: container }}>
<span style={{ position: "relative", display: "inline-flex" }}>
<Button
variant="secondary"
onClick={toggleLike}
attributes={{ "aria-label": "Like", "aria-pressed": liked }}
>
<svg
className="heart"
width="18"
height="18"
viewBox="0 0 24 24"
aria-hidden="true"
strokeWidth="2"
style={{
display: "block",
fill: liked ? "var(--vortex-color-foreground-critical-default)" : "none",
stroke: liked ? "var(--vortex-color-foreground-critical-default)" : "currentColor",
}}
>
<path d="M12 21s-7.5-4.6-9.6-9.4C1 8.2 3.2 4 7.2 4c2.1 0 3.6 1.1 4.8 2.8C13.2 5.1 14.7 4 16.8 4c4 0 6.2 4.2 4.8 7.6C19.5 16.4 12 21 12 21z" />
</svg>
</Button>
{particles.map((color, index) => (
<span
key={index}
className="particle"
style={{
position: "absolute",
zIndex: 1,
top: "50%",
left: "50%",
width: 6,
height: 6,
margin: -3,
borderRadius: 9999,
opacity: 0,
pointerEvents: "none",
background: `var(--vortex-color-background-${color}-default)`,
}}
/>
))}
</span>
<Text className="like-count" attributes={{ style: { display: "inline-block" } }}>
{liked ? 129 : 128} likes
</Text>
</Stack>
);
};
export default LikeButton;Match Vortex motion tokens
Vortex motion tokens are CSS variables, while GSAP takes durations in seconds and named eases. Copy the values from your generated theme.css: convert durations to seconds, and pass cubic-bezier() values to CustomEase.create, as in the dashboard example.
Reduced motion
Vortex components follow the system reduced motion setting. Check prefers-reduced-motion before animating, as both examples do, or register animations with gsap.matchMedia.