Order Book
Animated order book with bid and ask depth, spread, and volume balance.
Preview
| Price USD | Size SOL | Cumulative total SOL |
|---|---|---|
| 782 | 8.6K | |
| 1.2K | 7.8K | |
| 1.1K | 6.6K | |
| 2.2K | 5.5K | |
| 1.8K | 3.3K | |
| 278 | 1.5K | |
| 363 | 1.2K | |
| 574 | 858 | |
| 284 | 284 |
| Price USD | Size SOL | Cumulative total SOL |
|---|---|---|
| 194 | 194 | |
| 543 | 737 | |
| 314 | 1.1K | |
| 592 | 1.6K | |
| 715 | 2.4K | |
| 744 | 3.1K | |
| 789 | 3.9K | |
| 735 | 4.6K | |
| 1.1K | 5.7K |
"use client";
import {
OrderBook,
OrderBookBalance,
OrderBookHeader,
OrderBookSide,
OrderBookSpread,
type OrderBookLevel,
} from "@/components/charts/order-book";
/** Pass fresh aggregated snapshots from your data source to animate the depth. */
export function OrderBookExample({
bids,
asks,
}: {
bids: readonly OrderBookLevel[];
asks: readonly OrderBookLevel[];
}) {
return (
<OrderBook bids={bids} asks={asks} baseSymbol="SOL" quoteSymbol="USD" levels={9}>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookBalance />
</OrderBook>
);
}
"use client";
// beui.dev/charts/order-book
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, type ReactNode } from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { OrderBookDepthRow } from "./order-book/depth-row";
import { cn } from "@/lib/utils";
import { buildOrderBook } from "./order-book/model";
export type OrderBookLevel = { price: number; size: number };
export interface OrderBookProps {
/** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */
bids: readonly OrderBookLevel[];
/** Snapshot of sell levels. Supply new arrays when the book changes. */
asks: readonly OrderBookLevel[];
/** Number of nearest levels to show on each side. */
levels?: number;
/** Last traded price. Omit to show the midpoint of the best bid and ask. */
lastPrice?: number;
label?: string;
baseSymbol?: string;
quoteSymbol?: string;
formatPrice?: (value: number) => string;
formatSize?: (value: number) => string;
children?: ReactNode;
className?: string;
}
const priceFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const sizeFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const defaultPrice = (value: number) => priceFormatter.format(value);
const defaultSize = (value: number) => sizeFormatter.format(value);
function useOrderBookModel({
bids,
asks,
levels = 9,
lastPrice,
baseSymbol = "",
quoteSymbol = "USD",
formatPrice = defaultPrice,
formatSize = defaultSize,
}: OrderBookProps) {
const book = buildOrderBook(bids, asks, levels);
return {
...book,
price:
lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint,
baseSymbol,
quoteSymbol,
formatPrice,
formatSize,
reduce: useReducedMotion(),
};
}
const OrderBookContext = createContext<ReturnType<typeof useOrderBookModel> | null>(null);
export function useOrderBook() {
const context = useContext(OrderBookContext);
if (!context) throw new Error("Order book parts must be rendered inside OrderBook.");
return context;
}
/** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */
export function OrderBook({ children, className, label = "Order book", ...props }: OrderBookProps) {
const model = useOrderBookModel(props);
return (
<OrderBookContext.Provider value={model}>
<section
aria-label={label}
className={cn(
"w-full max-w-[560px] overflow-hidden rounded-2xl border border-border bg-background font-mono text-xs tabular-nums",
className,
)}
>
{children === undefined ? (
<>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookBalance />
</>
) : (
children
)}
</section>
</OrderBookContext.Provider>
);
}
export function OrderBookHeader({ className }: { className?: string }) {
const { baseSymbol, quoteSymbol } = useOrderBook();
return (
<div
aria-hidden="true"
className={cn(
"grid grid-cols-3 py-3 text-[10px] uppercase tracking-wider text-muted-foreground",
className,
)}
>
<span className="px-4">Price{quoteSymbol && ` (${quoteSymbol})`}</span>
<span className="px-4 text-right">Size{baseSymbol && ` (${baseSymbol})`}</span>
<span className="px-4 text-right">Total</span>
</div>
);
}
export function OrderBookSide({ side, className }: { side: "bid" | "ask"; className?: string }) {
const { bids, asks, maxTotal, formatPrice, formatSize, reduce, baseSymbol, quoteSymbol } =
useOrderBook();
const rows = side === "ask" ? [...asks].reverse() : bids;
const color =
side === "ask" ? "text-rose-600 dark:text-rose-400" : "text-emerald-700 dark:text-emerald-400";
return (
<table
aria-label={side === "ask" ? "Asks · sell orders" : "Bids · buy orders"}
className={cn("w-full table-fixed border-separate border-spacing-0", className)}
>
<thead className="sr-only">
<tr>
<th scope="col">Price {quoteSymbol}</th>
<th scope="col">Size {baseSymbol}</th>
<th scope="col">Cumulative total {baseSymbol}</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-muted-foreground">
No {side === "ask" ? "asks" : "bids"}
</td>
</tr>
) : (
rows.map((row, index) => (
<OrderBookDepthRow
key={row.price}
row={row}
fraction={maxTotal ? row.total / maxTotal : 0}
entranceIndex={side === "ask" ? rows.length - 1 - index : index}
color={color}
formatPrice={formatPrice}
formatSize={formatSize}
reduce={!!reduce}
/>
))
)}
</tbody>
</table>
);
}
export function OrderBookSpread({ className }: { className?: string }) {
const { price, spread, midpoint, formatPrice } = useOrderBook();
return (
<div
className={cn(
"my-1 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-y border-border bg-muted/40 px-4 py-3",
className,
)}
>
<span className="text-base font-medium tracking-tight">
<span className="sr-only">Reference price: </span>
{price == null ? "—" : formatPrice(price)}
</span>
<span className="text-[10px] text-muted-foreground">
{spread == null || midpoint == null
? "Spread unavailable"
: spread < 0
? "Crossed book"
: `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}
</span>
</div>
);
}
/** Balance of the displayed depth, not the entire exchange order book. */
export function OrderBookBalance({ className }: { className?: string }) {
const { bidTotal, askTotal, reduce } = useOrderBook();
const total = bidTotal + askTotal;
const ratio = total ? bidTotal / total : 0.5;
return (
<div className={cn("space-y-2 border-t border-border px-4 pb-3 pt-3", className)}>
<div className="flex justify-between text-[10px]">
<span className="text-emerald-700 dark:text-emerald-400">
Bids {total ? `${(ratio * 100).toFixed(1)}%` : "—"}
</span>
<span className="text-muted-foreground">Visible depth</span>
<span className="text-rose-600 dark:text-rose-400">
Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : "—"}
</span>
</div>
<div aria-hidden="true" className="relative h-1 overflow-hidden rounded-full bg-rose-500/25">
<motion.div
initial={false}
animate={{ transform: `scaleX(${ratio})` }}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
className="absolute inset-0 origin-left bg-emerald-500/60"
/>
</div>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/charts/order-book
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, type ReactNode } from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { OrderBookDepthRow } from "./order-book/depth-row";
import { cn } from "@/lib/utils";
import { buildOrderBook } from "./order-book/model";
export type OrderBookLevel = { price: number; size: number };
export interface OrderBookProps {
/** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */
bids: readonly OrderBookLevel[];
/** Snapshot of sell levels. Supply new arrays when the book changes. */
asks: readonly OrderBookLevel[];
/** Number of nearest levels to show on each side. */
levels?: number;
/** Last traded price. Omit to show the midpoint of the best bid and ask. */
lastPrice?: number;
label?: string;
baseSymbol?: string;
quoteSymbol?: string;
formatPrice?: (value: number) => string;
formatSize?: (value: number) => string;
children?: ReactNode;
className?: string;
}
const priceFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const sizeFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const defaultPrice = (value: number) => priceFormatter.format(value);
const defaultSize = (value: number) => sizeFormatter.format(value);
function useOrderBookModel({
bids,
asks,
levels = 9,
lastPrice,
baseSymbol = "",
quoteSymbol = "USD",
formatPrice = defaultPrice,
formatSize = defaultSize,
}: OrderBookProps) {
const book = buildOrderBook(bids, asks, levels);
return {
...book,
price:
lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint,
baseSymbol,
quoteSymbol,
formatPrice,
formatSize,
reduce: useReducedMotion(),
};
}
const OrderBookContext = createContext<ReturnType<typeof useOrderBookModel> | null>(null);
export function useOrderBook() {
const context = useContext(OrderBookContext);
if (!context) throw new Error("Order book parts must be rendered inside OrderBook.");
return context;
}
/** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */
export function OrderBook({ children, className, label = "Order book", ...props }: OrderBookProps) {
const model = useOrderBookModel(props);
return (
<OrderBookContext.Provider value={model}>
<section
aria-label={label}
className={cn(
"w-full max-w-[560px] overflow-hidden rounded-2xl border border-border bg-background font-mono text-xs tabular-nums",
className,
)}
>
{children === undefined ? (
<>
<OrderBookHeader />
<OrderBookSide side="ask" />
<OrderBookSpread />
<OrderBookSide side="bid" />
<OrderBookBalance />
</>
) : (
children
)}
</section>
</OrderBookContext.Provider>
);
}
export function OrderBookHeader({ className }: { className?: string }) {
const { baseSymbol, quoteSymbol } = useOrderBook();
return (
<div
aria-hidden="true"
className={cn(
"grid grid-cols-3 py-3 text-[10px] uppercase tracking-wider text-muted-foreground",
className,
)}
>
<span className="px-4">Price{quoteSymbol && ` (${quoteSymbol})`}</span>
<span className="px-4 text-right">Size{baseSymbol && ` (${baseSymbol})`}</span>
<span className="px-4 text-right">Total</span>
</div>
);
}
export function OrderBookSide({ side, className }: { side: "bid" | "ask"; className?: string }) {
const { bids, asks, maxTotal, formatPrice, formatSize, reduce, baseSymbol, quoteSymbol } =
useOrderBook();
const rows = side === "ask" ? [...asks].reverse() : bids;
const color =
side === "ask" ? "text-rose-600 dark:text-rose-400" : "text-emerald-700 dark:text-emerald-400";
return (
<table
aria-label={side === "ask" ? "Asks · sell orders" : "Bids · buy orders"}
className={cn("w-full table-fixed border-separate border-spacing-0", className)}
>
<thead className="sr-only">
<tr>
<th scope="col">Price {quoteSymbol}</th>
<th scope="col">Size {baseSymbol}</th>
<th scope="col">Cumulative total {baseSymbol}</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-muted-foreground">
No {side === "ask" ? "asks" : "bids"}
</td>
</tr>
) : (
rows.map((row, index) => (
<OrderBookDepthRow
key={row.price}
row={row}
fraction={maxTotal ? row.total / maxTotal : 0}
entranceIndex={side === "ask" ? rows.length - 1 - index : index}
color={color}
formatPrice={formatPrice}
formatSize={formatSize}
reduce={!!reduce}
/>
))
)}
</tbody>
</table>
);
}
export function OrderBookSpread({ className }: { className?: string }) {
const { price, spread, midpoint, formatPrice } = useOrderBook();
return (
<div
className={cn(
"my-1 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-y border-border bg-muted/40 px-4 py-3",
className,
)}
>
<span className="text-base font-medium tracking-tight">
<span className="sr-only">Reference price: </span>
{price == null ? "—" : formatPrice(price)}
</span>
<span className="text-[10px] text-muted-foreground">
{spread == null || midpoint == null
? "Spread unavailable"
: spread < 0
? "Crossed book"
: `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}
</span>
</div>
);
}
/** Balance of the displayed depth, not the entire exchange order book. */
export function OrderBookBalance({ className }: { className?: string }) {
const { bidTotal, askTotal, reduce } = useOrderBook();
const total = bidTotal + askTotal;
const ratio = total ? bidTotal / total : 0.5;
return (
<div className={cn("space-y-2 border-t border-border px-4 pb-3 pt-3", className)}>
<div className="flex justify-between text-[10px]">
<span className="text-emerald-700 dark:text-emerald-400">
Bids {total ? `${(ratio * 100).toFixed(1)}%` : "—"}
</span>
<span className="text-muted-foreground">Visible depth</span>
<span className="text-rose-600 dark:text-rose-400">
Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : "—"}
</span>
</div>
<div aria-hidden="true" className="relative h-1 overflow-hidden rounded-full bg-rose-500/25">
<motion.div
initial={false}
animate={{ transform: `scaleX(${ratio})` }}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
className="absolute inset-0 origin-left bg-emerald-500/60"
/>
</div>
</div>
);
}
"use client";
import { animate, motion, useMotionValue } from "motion/react";
import { useEffect, useRef } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type { DepthLevel } from "./model";
export function OrderBookDepthRow({
row,
fraction,
entranceIndex,
color,
formatPrice,
formatSize,
reduce,
}: {
row: DepthLevel;
fraction: number;
entranceIndex: number;
color: string;
formatPrice: (value: number) => string;
formatSize: (value: number) => string;
reduce: boolean;
}) {
const highlight = useMotionValue(0);
const previousSize = useRef(row.size);
const entered = useRef(false);
useEffect(() => {
entered.current = true;
}, []);
useEffect(() => {
const changed = previousSize.current !== row.size;
previousSize.current = row.size;
if (!changed || reduce) {
highlight.set(0);
return;
}
// A soft full-row tint identifies changed quantities. Retarget from
// the current opacity when another snapshot interrupts the fade.
let fade: ReturnType<typeof animate> | undefined;
const rise = animate(highlight, 0.1, {
duration: 0.08,
ease: EASE_OUT,
onComplete: () => {
fade = animate(highlight, 0, { duration: 0.2, ease: EASE_OUT });
},
});
return () => {
rise.stop();
fade?.stop();
};
}, [row.size, reduce, highlight]);
return (
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.18,
ease: EASE_OUT,
delay: reduce || entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,
}}
className={cn("group", color)}
>
<td className="relative h-8 px-4 py-0">
<div
aria-hidden="true"
className="pointer-events-none absolute left-1 inset-y-px w-[calc(300%-8px)] overflow-hidden rounded-sm"
>
<motion.div
initial={reduce ? false : { transform: `scaleX(${fraction * 0.85})`, opacity: 0 }}
animate={{ transform: `scaleX(${fraction})`, opacity: 1 }}
transition={
reduce
? { duration: 0 }
: {
...SPRING_PANEL,
delay: entered.current ? 0 : Math.min(entranceIndex, 8) * 0.025,
}
}
className="absolute inset-0 origin-right overflow-hidden rounded-sm"
>
<div className="absolute inset-0 bg-current opacity-[0.12]" />
</motion.div>
<motion.div style={{ opacity: highlight }} className="absolute inset-0 bg-current" />
<div className="absolute inset-0 bg-current opacity-0 transition-opacity duration-150 group-hover:opacity-[0.05]" />
</div>
<span className="relative">{formatPrice(row.price)}</span>
</td>
<td className="relative px-4 py-0 text-right text-foreground">
{formatSize(row.size)}
</td>
<td className="relative px-4 py-0 text-right text-muted-foreground">
{formatSize(row.total)}
</td>
</motion.tr>
);
}
export type DepthLevel = { price: number; size: number; total: number };
type Level = { price: number; size: number };
function prepare(levels: readonly Level[], side: "bid" | "ask", limit: number): DepthLevel[] {
const prices = new Map<number, number>();
for (const { price, size } of levels) {
if (!Number.isFinite(price) || !Number.isFinite(size) || price <= 0 || size <= 0) continue;
prices.set(price, (prices.get(price) ?? 0) + size);
}
let total = 0;
return [...prices]
.sort(([a], [b]) => (side === "bid" ? b - a : a - b))
.slice(0, limit)
.map(([price, size]) => {
total += size;
return { price, size, total };
});
}
/** Accumulate from the best quote outward before reversing asks for display. */
export function buildOrderBook(bids: readonly Level[], asks: readonly Level[], levels: number) {
const limit = Number.isFinite(levels) ? Math.max(0, Math.floor(levels)) : 9;
const buy = prepare(bids, "bid", limit);
const sell = prepare(asks, "ask", limit);
const bidTotal = buy.at(-1)?.total ?? 0;
const askTotal = sell.at(-1)?.total ?? 0;
const bestBid = buy[0]?.price;
const bestAsk = sell[0]?.price;
const midpoint = bestBid == null || bestAsk == null ? null : (bestBid + bestAsk) / 2;
const spread = bestBid == null || bestAsk == null ? null : bestAsk - bestBid;
return {
bids: buy,
asks: sell,
bidTotal,
askTotal,
maxTotal: Math.max(bidTotal, askTotal),
midpoint,
spread,
};
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
OrderBook
bidsreadonly OrderBookLevel[]Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted.
—asksreadonly OrderBookLevel[]Snapshot of sell levels. Supply new arrays when the book changes.
—levels?numberNumber of nearest levels to show on each side.
—lastPrice?numberLast traded price. Omit to show the midpoint of the best bid and ask.
—label?stringOrder bookbaseSymbol?string—quoteSymbol?string—formatPrice?((value: number) => string)—formatSize?((value: number) => string)—className?string—OrderBookHeader
className?string—OrderBookSide
side"bid" | "ask"—className?string—OrderBookSpread
className?string—OrderBookBalance
className?string—Related components
Liquidity Heatmap
Animated liquidity bands across price and time, with a price trace and depth tooltips.
Heat Calendar
Composable activity calendar with Grid, Legend, and Tooltip parts, controlled range selection, and UTC calendar dates. Supply weekly intensities to visualize activity and total a selected span.
Returns Calendar
Composable monthly returns calendar with Grid and Tooltip parts, controlled month or year selections, and compounded totals. Supply your own years and monthly percentages.
Updated