Skip to main content

GSAP

Animate layouts built with Vortex components using GSAP and its React hook, useGSAP.

bun add gsap @gsap/react

Target components

  • Pass a ref through attributes, such as attributes={{ ref: container }}, to components that don't accept ref directly.
  • Vortex class names are generated, so add your own className to elements you animate.
  • Set the container as the useGSAP scope. 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:

Revenue

+12%

$48,290

"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.