Chakra UI v2 · Next.js · TypeScript. View & copy the source for each animation below.
Every component below imports keyframes from one file and reads tokens from the Chakra theme. Grab these two first, then any component.
The signature moment: points count down while USDC counts up, source chips pop in on a stagger, each arrival re-triggers a scale tick.
234 lines
// ───────── animations/CinematicClaim.tsx ─────────
"use client";
import { useEffect, useState } from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import { Sparkles } from "lucide-react";
import { FloatyArrow } from "./FloatyArrow";
import { tickp, popIn, springEase } from "./keyframes";
import { fmt, fmtUsdc } from "./format";
export interface ClaimChip {
key: string;
label: string;
}
interface CinematicClaimProps {
/** Starting eligible points (counts down). */
totalPts: number;
/** Resulting USDC allocation (counts up). */
totalAlloc: number;
/** Source chips revealed one by one as the conversion lands. */
chips?: ClaimChip[];
/** Fired once the full sequence (plus a 1s hold) completes. */
onDone?: () => void;
}
/**
* The signature "converting your points into USDC" moment:
*
* - points count DOWN and USDC counts UP together over 1.8s (rAF, cubic ease)
* - the points figure also fades as it drains
* - source chips pop in on a stagger (800ms + 340ms each)
* - every chip arrival re-triggers a scale "tick" on the USDC number
* - `onDone` fires 1s after the count finishes
*
* Render inside <PopModal hideClose> (or any centered container).
*/
export function CinematicClaim({
totalPts,
totalAlloc,
chips = [],
onDone,
}: CinematicClaimProps) {
const [pts, setPts] = useState(totalPts);
const [usdc, setUsdc] = useState(0);
const [shown, setShown] = useState(0);
const [pulse, setPulse] = useState(0);
useEffect(() => {
let raf = 0;
const t0 = performance.now();
const D = 1800;
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / D);
const e = 1 - Math.pow(1 - p, 3);
setPts(Math.round(totalPts * (1 - e)));
setUsdc(Math.round(totalAlloc * e));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const timers: ReturnType<typeof setTimeout>[] = [];
chips.forEach((_, i) => {
timers.push(
setTimeout(() => {
setShown(i + 1);
setPulse((x) => x + 1);
}, 800 + i * 340)
);
});
if (onDone) timers.push(setTimeout(onDone, D + 1000));
return () => {
cancelAnimationFrame(raf);
timers.forEach(clearTimeout);
};
// run once on mount, mirroring the prototype
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box w="min(520px,94vw)" textAlign="center">
<Flex
as="span"
display="inline-flex"
align="center"
gap={1.5}
bg="surface.track"
borderRadius="chip"
px={3}
py={1.5}
fontSize="12px"
fontWeight={700}
color="text.muted"
>
<Sparkles size={12} /> Claiming your allocation
</Flex>
<Text
mt={5}
fontSize="11px"
fontWeight={700}
letterSpacing="0.6px"
color="text.muted"
>
YOUR ELIGIBLE POINTS
</Text>
<Box
fontSize="38px"
fontWeight={700}
letterSpacing="-1.5px"
opacity={0.35 + 0.65 * (totalPts ? pts / totalPts : 0)}
transition="opacity .2s"
>
{fmt(pts)}{" "}
<Box as="small" fontSize="14px" fontWeight={600}>
pts
</Box>
</Box>
<FloatyArrow />
<Text fontSize="11px" fontWeight={700} letterSpacing="0.6px" color="text.muted">
USDC ALLOCATION
</Text>
<Flex
key={pulse}
display="inline-flex"
align="center"
gap={2.5}
fontSize="58px"
fontWeight={700}
letterSpacing="-2px"
fontFamily="numeric"
animation={`${tickp} 0.28s ease`}
>
{fmtUsdc(usdc)}
</Flex>
<Flex
justify="center"
gap={2}
wrap="wrap"
m="18px 0 6px"
minH="30px"
>
{chips.slice(0, shown).map((c) => (
<Box
key={c.key}
as="span"
bg="grass.lime"
color="text.onLime"
border="1px solid"
borderColor="border.strong"
borderRadius="chip"
px={3}
py={1}
fontSize="12px"
fontWeight={700}
animation={`${popIn} 0.3s ${springEase} both`}
>
+{c.label}
</Box>
))}
</Flex>
<Text fontSize="11.5px" color="text.muted">
Converting your uptime and network points into your USDC allocation.
</Text>
</Box>
);
}
// ───────── animations/FloatyArrow.tsx ─────────
"use client";
import { Flex, type FlexProps } from "@chakra-ui/react";
import { ArrowDown } from "lucide-react";
import { floaty } from "./keyframes";
/** Continuously bobbing downward arrow — the points→USDC conversion cue. */
export function FloatyArrow({
size = 22,
...rest
}: FlexProps & { size?: number }) {
return (
<Flex
justify="center"
my={2}
animation={`${floaty} 1.1s ease-in-out infinite`}
{...rest}
>
<ArrowDown size={size} strokeWidth={2.6} />
</Flex>
);
}
// ───────── animations/useCountUp.ts ─────────
"use client";
import { useEffect, useState } from "react";
/**
* Animates an integer from 0 → `target` with a cubic ease-out over `dur` ms,
* driven by requestAnimationFrame. Ported verbatim from the prototype's
* `useCountUp`, with types added.
*/
export function useCountUp(target: number, dur = 1100): number {
const [value, setValue] = useState(0);
useEffect(() => {
let raf = 0;
const t0 = performance.now();
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / dur);
setValue(Math.round(target * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, dur]);
return value;
}
// ───────── animations/format.ts ─────────
/** Thousands-separated integer, e.g. 1234567 → "1,234,567". */
export const fmt = (n: number): string => Math.round(n).toLocaleString("en-US");
/** USDC allocation, e.g. 4820 → "$4,820". */
export const fmtUsdc = (n: number): string => `$${fmt(n)}`;Falling confetti rains behind a counting-up USDC figure. Deterministic layout, so it's SSR-safe.
210 lines
// ───────── animations/ConfettiSuccess.tsx ─────────
"use client";
import { Box, Flex, Text, Button } from "@chakra-ui/react";
import { Confetti } from "./Confetti";
import { CountUp } from "./CountUp";
import { fmtUsdc } from "./format";
interface ConfettiSuccessProps {
/** USDC amount to count up to. */
amount: number;
/** Small recap chips (e.g. networks claimed). */
chips?: string[];
onShare?: () => void;
onWallet?: () => void;
}
/**
* The "claim succeeded" celebration: confetti rains behind a counting-up
* USDC figure. Intended to be rendered inside <PopModal>.
*/
export function ConfettiSuccess({
amount,
chips = [],
onShare,
onWallet,
}: ConfettiSuccessProps) {
return (
<Box position="relative" textAlign="center" overflow="hidden">
<Confetti />
<Box position="relative" zIndex={1}>
<Box
w="64px"
h="64px"
borderRadius="999px"
bg="grass.lime"
color="text.onLime"
border="1px solid"
borderColor="border.strong"
boxShadow="drop"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="30px"
fontWeight={700}
m="6px auto 14px"
>
✓
</Box>
<Text fontSize="26px" fontWeight={700} mb={2}>
You're all set!
</Text>
<CountUp
to={amount}
format={fmtUsdc}
fontSize="74px"
fontWeight={700}
letterSpacing="-3px"
lineHeight="1"
display="block"
/>
<Text fontSize="20px" fontWeight={600} letterSpacing="2px" mb="10px">
USDC CLAIMED
</Text>
{chips.length > 0 && (
<Flex justify="center" gap={2.5} wrap="wrap" mb="22px">
{chips.map((c) => (
<Box
key={c}
as="span"
border="1.5px solid"
borderColor="border.strong"
borderRadius="999px"
px={3}
py={1}
fontSize="12px"
fontWeight={700}
bg="surface.cardLime"
>
+{c}
</Box>
))}
</Flex>
)}
<Flex gap={3} justify="center">
<Button
onClick={onShare}
bg="grass.lime"
color="text.onLime"
borderRadius="pill"
_hover={{ filter: "brightness(0.96)" }}
>
Share
</Button>
<Button
onClick={onWallet}
bg="surface.card"
color="text.primary"
border="1px solid"
borderColor="border.strong"
borderRadius="pill"
_hover={{ bg: "surface.track" }}
>
View wallet
</Button>
</Flex>
</Box>
</Box>
);
}
// ───────── animations/Confetti.tsx ─────────
"use client";
import { Box, type BoxProps } from "@chakra-ui/react";
import { fall } from "./keyframes";
const COLORS = [
"var(--chakra-colors-grass-lime)",
"var(--chakra-colors-grass-chartBlue)",
"var(--chakra-colors-text-primary)",
"var(--chakra-colors-surface-cardLime)",
"var(--chakra-colors-grass-lime750)",
];
interface ConfettiProps extends BoxProps {
/** Number of pieces (default 34, matching the prototype). */
count?: number;
/** Fall distance in px (default 620). */
distance?: number;
}
/**
* Absolutely-positioned confetti layer. Drop it inside a `position:relative`,
* `overflow:hidden` container; pieces start above the top edge and tumble down
* on a loop with staggered delays. Deterministic layout (no Math.random) so it
* is SSR-safe.
*/
export function Confetti({ count = 34, distance = 620, ...rest }: ConfettiProps) {
const fallAnim = fall(distance);
return (
<Box
position="absolute"
inset={0}
pointerEvents="none"
overflow="hidden"
borderRadius="lg"
{...rest}
>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
position="absolute"
top="-20px"
borderRadius="2px"
border="1px solid"
borderColor="hairline"
left={`${(i * 100) / count + (i % 4) * 1.7}%`}
w={i % 2 ? "9px" : "6px"}
h={i % 3 ? "13px" : "8px"}
bg={COLORS[i % COLORS.length]}
transform={`rotate(${i * 23}deg)`}
animation={`${fallAnim} 2.8s linear infinite`}
style={{ animationDelay: `${(i % 9) * 0.21}s` }}
/>
))}
</Box>
);
}
// ───────── animations/CountUp.tsx ─────────
"use client";
import { Text, type TextProps } from "@chakra-ui/react";
import { useCountUp } from "./useCountUp";
import { fmt } from "./format";
interface CountUpProps extends Omit<TextProps, "children"> {
/** Final value to count up to. */
to: number;
/** Duration in ms (default 1100). */
duration?: number;
/** Optional formatter; defaults to thousands-separated integer. */
format?: (n: number) => string;
}
/**
* Counts an integer up to `to` on mount using the cubic ease-out rAF hook.
* Uses the `numeric` (Inter) font and tabular figures so digits don't jitter.
*/
export function CountUp({
to,
duration = 1100,
format = fmt,
...rest
}: CountUpProps) {
const value = useCountUp(to, duration);
return (
<Text
as="span"
fontFamily="numeric"
sx={{ fontVariantNumeric: "tabular-nums" }}
{...rest}
>
{format(value)}
</Text>
);
}requestAnimationFrame integer count with a cubic ease-out. Tabular figures, Inter.
75 lines
// ───────── animations/CountUp.tsx ─────────
"use client";
import { Text, type TextProps } from "@chakra-ui/react";
import { useCountUp } from "./useCountUp";
import { fmt } from "./format";
interface CountUpProps extends Omit<TextProps, "children"> {
/** Final value to count up to. */
to: number;
/** Duration in ms (default 1100). */
duration?: number;
/** Optional formatter; defaults to thousands-separated integer. */
format?: (n: number) => string;
}
/**
* Counts an integer up to `to` on mount using the cubic ease-out rAF hook.
* Uses the `numeric` (Inter) font and tabular figures so digits don't jitter.
*/
export function CountUp({
to,
duration = 1100,
format = fmt,
...rest
}: CountUpProps) {
const value = useCountUp(to, duration);
return (
<Text
as="span"
fontFamily="numeric"
sx={{ fontVariantNumeric: "tabular-nums" }}
{...rest}
>
{format(value)}
</Text>
);
}
// ───────── animations/useCountUp.ts ─────────
"use client";
import { useEffect, useState } from "react";
/**
* Animates an integer from 0 → `target` with a cubic ease-out over `dur` ms,
* driven by requestAnimationFrame. Ported verbatim from the prototype's
* `useCountUp`, with types added.
*/
export function useCountUp(target: number, dur = 1100): number {
const [value, setValue] = useState(0);
useEffect(() => {
let raf = 0;
const t0 = performance.now();
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / dur);
setValue(Math.round(target * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, dur]);
return value;
}
// ───────── animations/format.ts ─────────
/** Thousands-separated integer, e.g. 1234567 → "1,234,567". */
export const fmt = (n: number): string => Math.round(n).toLocaleString("en-US");
/** USDC allocation, e.g. 4820 → "$4,820". */
export const fmtUsdc = (n: number): string => `$${fmt(n)}`;The line draws via animated stroke-dashoffset, the fill fades in, then the live dot pulses (animating the SVG r attribute).
133 lines
"use client";
import { Box, type BoxProps } from "@chakra-ui/react";
import { drawline, pulse, fadeIn } from "./keyframes";
interface DrawLineChartProps extends BoxProps {
/** Series values, normalized internally to the view box. */
data?: number[];
width?: number;
height?: number;
color?: string;
}
const DEFAULT_DATA = [8, 12, 10, 18, 16, 26, 24, 34, 40, 52, 60, 78];
/** Build a smooth-ish polyline path string across the view box. */
function linePath(data: number[], w: number, h: number, pad: number, max: number) {
const span = w - 2 * pad;
const inner = h - 2 * pad;
return data
.map((v, i) => {
const x = pad + (i / (data.length - 1)) * span;
const y = h - pad - (v / max) * inner;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
})
.join(" ");
}
/**
* The journey-chart reveal: the line "draws on" via animated stroke-dashoffset
* (using `pathLength={1}` so a dasharray of 1 always equals the full length),
* the gradient fill fades in after, and the live end-dot pulses forever.
*/
export function DrawLineChart({
data = DEFAULT_DATA,
width = 780,
height = 250,
color = "var(--chakra-colors-grass-chartBlue)",
...rest
}: DrawLineChartProps) {
const pad = 14;
const max = Math.max(...data, 1);
const path = linePath(data, width, height, pad, max);
const lastX = width - pad;
const lastY = height - pad - (data[data.length - 1] / max) * (height - 2 * pad);
const areaPath = `${path} L${lastX} ${height - pad} L${pad} ${height - pad} Z`;
return (
<Box {...rest}>
<Box
as="svg"
viewBox={`0 0 ${width} ${height}`}
width="100%"
sx={{ display: "block", overflow: "visible" }}
>
<defs>
<linearGradient id="drawFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.42" />
<stop offset="100%" stopColor={color} stopOpacity="0.03" />
</linearGradient>
</defs>
{/* gridlines */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={pad}
x2={width - pad}
y1={pad + f * (height - 2 * pad)}
y2={pad + f * (height - 2 * pad)}
stroke="var(--chakra-colors-hairline)"
strokeWidth="1"
strokeDasharray="3 5"
/>
))}
{/* fill fades in after the line finishes drawing */}
<Box
as="path"
d={areaPath}
fill="url(#drawFill)"
opacity={0}
sx={{ animation: `${fadeIn} 0.6s ease 0.95s forwards` }}
/>
{/* the line, drawn on */}
<Box
as="path"
d={path}
fill="none"
stroke={color}
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
// pathLength=1 normalizes the path so a dasharray/offset of 1 always
// equals the full length (forwarded as a raw SVG attribute).
{...({ pathLength: 1 } as Record<string, number>)}
sx={{
strokeDasharray: 1,
strokeDashoffset: 1,
animation: `${drawline} 1.15s ease 0.15s forwards`,
}}
/>
{/* pulsing live point (ring animates its `r`) */}
<Box
as="circle"
cx={lastX}
cy={lastY}
r={9}
fill="none"
stroke={color}
strokeWidth={3.5}
opacity={0}
sx={{
animation: `${fadeIn} 0.3s ease 1.15s both, ${pulse} 1.8s ease-out 1.6s infinite`,
}}
/>
<Box
as="circle"
cx={lastX}
cy={lastY}
r={6.5}
fill={color}
stroke="var(--chakra-colors-surface-card)"
strokeWidth={1.5}
opacity={0}
sx={{ animation: `${fadeIn} 0.3s ease 1.15s forwards` }}
/>
</Box>
</Box>
);
}200%-wide gradient swept by the shimmer keyframe. Mode-aware.
27 lines
"use client";
import { Box, type BoxProps, useColorModeValue } from "@chakra-ui/react";
import { shimmer } from "./keyframes";
/**
* Loading skeleton with the prototype's left→right sweep. The gradient is
* 200% wide and the `shimmer` keyframe slides its background-position.
*/
export function SkeletonShimmer(props: BoxProps) {
// Light: warm off-white sweep. Dark: a subtle slate sweep.
const gradient = useColorModeValue(
"linear-gradient(90deg,#E8EADB 25%,#F4F5EC 50%,#E8EADB 75%)",
"linear-gradient(90deg,#2C2C2C 25%,#3A3A3A 50%,#2C2C2C 75%)"
);
return (
<Box
bg={gradient}
bgSize="200% 100%"
borderRadius="sm"
border="1.5px solid"
borderColor="hairline"
animation={`${shimmer} 1.4s infinite`}
{...props}
/>
);
}Opacity + 8px rise on mount. Used for panels, error strips, toasts.
31 lines
"use client";
import { Box, type BoxProps } from "@chakra-ui/react";
import { fadeUp, fadeIn } from "./keyframes";
interface FadeUpProps extends BoxProps {
/** Use a plain opacity fade instead of fade + rise. */
plain?: boolean;
/** Delay in seconds before the reveal starts. */
delay?: number;
duration?: number;
}
/**
* On-mount reveal wrapper: `fadeUp` (opacity + 8px rise) by default, or a plain
* `fadeIn`. Used by the prototype's notification panel, error strips, etc.
*/
export function FadeUp({
plain,
delay = 0,
duration = 0.2,
children,
...rest
}: FadeUpProps) {
const name = plain ? fadeIn : fadeUp;
return (
<Box animation={`${name} ${duration}s ease ${delay}s both`} {...rest}>
{children}
</Box>
);
}Continuous loops: the conversion arrow bobs (floaty); the loader spins.
45 lines
// ───────── animations/FloatyArrow.tsx ─────────
"use client";
import { Flex, type FlexProps } from "@chakra-ui/react";
import { ArrowDown } from "lucide-react";
import { floaty } from "./keyframes";
/** Continuously bobbing downward arrow — the points→USDC conversion cue. */
export function FloatyArrow({
size = 22,
...rest
}: FlexProps & { size?: number }) {
return (
<Flex
justify="center"
my={2}
animation={`${floaty} 1.1s ease-in-out infinite`}
{...rest}
>
<ArrowDown size={size} strokeWidth={2.6} />
</Flex>
);
}
// ───────── animations/Spinner.tsx ─────────
"use client";
import { Box, type BoxProps } from "@chakra-ui/react";
import { Loader2 } from "lucide-react";
import { spin } from "./keyframes";
/** Continuously rotating loader icon. */
export function Spinner({ size = 22, ...rest }: BoxProps & { size?: number }) {
return (
<Box
as="span"
display="inline-flex"
animation={`${spin} 0.8s linear infinite`}
{...rest}
>
<Loader2 size={size} />
</Box>
);
}Backdrop fades in; the card springs in with an overshoot ease.
74 lines
"use client";
import { Box, Flex, type BoxProps } from "@chakra-ui/react";
import { X } from "lucide-react";
import { fadeIn, popIn, springEase } from "./keyframes";
interface PopModalProps extends Omit<BoxProps, "onClick"> {
isOpen: boolean;
onClose?: () => void;
/** Hide the corner close button (e.g. for an auto-advancing sequence). */
hideClose?: boolean;
children: React.ReactNode;
}
/**
* Backdrop fades in; the card springs in with the prototype's `popIn`
* (overshoot easing). Built from primitives rather than Chakra's <Modal>
* so the exact keyframes/timing carry over. Backdrop click closes; clicks
* inside the card are stopped.
*/
export function PopModal({
isOpen,
onClose,
hideClose,
children,
...rest
}: PopModalProps) {
if (!isOpen) return null;
return (
<Flex
position="fixed"
inset={0}
zIndex={60}
align="center"
justify="center"
p={5}
bg="rgba(43,48,28,.62)"
animation={`${fadeIn} 0.2s ease both`}
onClick={onClose}
>
<Box
position="relative"
bg="surface.card"
border="1px solid"
borderColor="border.strong"
borderRadius="lg"
boxShadow="drop"
w="min(620px, 94vw)"
maxH="92vh"
overflowY="auto"
p="30px 32px"
animation={`${popIn} 0.28s ${springEase} both`}
onClick={(e) => e.stopPropagation()}
{...rest}
>
{!hideClose && onClose && (
<Box
as="button"
aria-label="Close"
position="absolute"
top="18px"
right="18px"
color="text.primary"
p="6px"
onClick={onClose}
>
<X size={20} />
</Box>
)}
{children}
</Box>
</Flex>
);
}