Option Chain
Signature workflowsNIFTY-style option chain: Greeks toggle, ATM highlighting, OI data bars, restrained tick flashes and in-context depth with buy/sell.
Spot24,812.40
| Calls | Strike | Puts | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| OI | Chg OI | Vol | IV | LTP | Chg% | ₹ | Chg% | LTP | IV | Vol | Chg OI | OI |
| 20.7L | +78.0K | 12.4L | 13.1% | 318.41 | +2.8% | 24,550 | +6.2% | 38.53 | 13.1% | 15.1L | +2.9L | 29.8L |
| 30.0L | +3.9L | 12.9L | 13.0% | 279.62 | -2.0% | 24,600 | -8.0% | 49.70 | 13.0% | 17.5L | -47.4K | 24.4L |
| 32.0L | +3.8L | 13.6L | 13.0% | 243.19 | +5.6% | 24,650 | -2.0% | 63.24 | 13.0% | 21.5L | +67.3K | 42.2L |
| 43.8L | +1.3L | 16.0L | 13.0% | 209.36 | -6.4% | 24,700 | +4.4% | 79.37 | 13.0% | 17.3L | +4.8L | 46.2L |
| 55.3L | +78.6K | 26.2L | 13.0% | 178.32 | +8.5% | 24,750 | +7.4% | 98.29 | 13.0% | 31.0L | -3.9L | 37.7L |
| 63.9L | +71.6K | 19.3L | 13.0% | 150.19 | +9.2% | 24,800ATM | +7.4% | 120.14 | 13.0% | 20.0L | -1.0L | 45.2L |
| 48.3L | -3.7L | 18.8L | 13.0% | 125.06 | -5.9% | 24,850 | -5.4% | 144.96 | 13.0% | 22.5L | +2.2L | 40.9L |
| 32.6L | -1.8L | 24.1L | 13.0% | 102.91 | -2.1% | 24,900 | -4.5% | 172.77 | 13.0% | 27.6L | +2.2L | 40.3L |
| 29.1L | +4.2L | 19.5L | 13.0% | 83.67 | +3.1% | 24,950 | +3.0% | 203.50 | 13.0% | 19.1L | -85.3K | 29.7L |
| 25.9L | -3.5L | 12.3L | 13.0% | 67.20 | +7.9% | 25,000 | -9.6% | 237.00 | 13.0% | 14.5L | -1.4L | 33.0L |
| 28.8L | -37.9K | 9.7L | 13.1% | 53.33 | +5.2% | 25,050 | +2.5% | 273.09 | 13.1% | 13.8L | +3.7L | 32.2L |
Lot size 75 · click a strike for depth and orders · simulated data
Install
npx shadcn@latest add https://labs.duku.design/r/option-chain.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Option Chain component (get_component "option-chain", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Watch the spot tick | Changed LTPs flash market-up/market-down for 600ms, then settle — no full-table repaints |
| Click a strike row | Bid/ask depth with Buy/Sell expands in context below the row (height tween, power3.out) |
| Toggle Greeks | OI/volume columns swap to Δ Γ Θ V computed from Black-Scholes |
| Switch expiry | Rows re-quote for the new DTE; premiums and Greeks stay coherent |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| rows / spot | OptionChainRow[] / number | — | Chain data (see generateOptionChain) and spot price. |
| expiries / expiry / onExpiryChange | string[] / string / fn | — | Expiry tabs. |
| defaultShowGreeks | boolean | false | Start on Greek columns. |
| compact | boolean | false | OI + LTP only, for narrow containers. |
| onOrder | (order: OptionOrder) => void | — | Buy/Sell from the depth panel. |
| lotSize | number | 75 | Lot size shown on order buttons. |
Source — registry/default/fintech/option-chain.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import {
blackScholes,
smileIv,
} from "@/registry/default/lib/black-scholes";
/* ------------------------------------------------------------------ */
/* Data model + example-data generator */
/* ------------------------------------------------------------------ */
export interface OptionQuote {
ltp: number;
/** Percent change on the day. */
chg: number;
oi: number;
chgOi: number;
volume: number;
/** Implied volatility as a fraction, e.g. 0.14. */
iv: number;
bid: number;
ask: number;
bidQty: number;
askQty: number;
delta: number;
gamma: number;
theta: number;
vega: number;
}
export interface OptionChainRow {
strike: number;
call: OptionQuote;
put: OptionQuote;
}
/** Deterministic pseudo-random in [0,1) seeded by strike + salt. */
const prand = (seed: number) => {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
export interface GenerateOptionChainOptions {
spot: number;
/** Strike interval. Default 50 (NIFTY). */
step?: number;
/** Strikes on each side of ATM. Default 8. */
span?: number;
/** Days to expiry. Default 7. */
dte?: number;
}
/** Coherent example data: premiums and Greeks come from Black-Scholes. */
export function generateOptionChain({
spot,
step = 50,
span = 8,
dte = 7,
}: GenerateOptionChainOptions): OptionChainRow[] {
const atm = Math.round(spot / step) * step;
const t = dte / 365;
const rows: OptionChainRow[] = [];
for (let i = -span; i <= span; i++) {
const strike = atm + i * step;
const iv = smileIv(spot, strike);
const quote = (side: "call" | "put"): OptionQuote => {
const g = blackScholes({ s: spot, k: strike, t, iv, side });
const salt = strike + (side === "call" ? 1 : 2) * 1000 + dte;
const near = Math.exp(-Math.abs(strike - spot) / (step * 4));
const ltp = Math.max(0.05, g.price);
const spread = Math.max(0.05, ltp * 0.004 + 0.05);
return {
ltp,
chg: (prand(salt) - (side === "call" ? 0.45 : 0.55)) * 18,
oi: Math.round((0.4 + near) * (1.5 + prand(salt + 1)) * 2_000_000),
chgOi: Math.round((prand(salt + 2) - 0.45) * 900_000),
volume: Math.round((0.3 + near) * (1 + prand(salt + 3)) * 1_500_000),
iv,
bid: ltp - spread / 2,
ask: ltp + spread / 2,
bidQty: Math.round((1 + prand(salt + 4) * 9) * 75),
askQty: Math.round((1 + prand(salt + 5) * 9) * 75),
delta: g.delta,
gamma: g.gamma,
theta: g.theta,
vega: g.vega,
};
};
rows.push({ strike, call: quote("call"), put: quote("put") });
}
return rows;
}
/* ------------------------------------------------------------------ */
/* Formatting */
/* ------------------------------------------------------------------ */
const nf0 = new Intl.NumberFormat("en-IN");
const nf2 = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const compactOi = (n: number) =>
Math.abs(n) >= 100_000
? `${(n / 100_000).toFixed(1)}L`
: Math.abs(n) >= 1_000
? `${(n / 1_000).toFixed(1)}K`
: nf0.format(n);
/* ------------------------------------------------------------------ */
/* Tick cell: restrained directional flash when a value changes */
/* ------------------------------------------------------------------ */
function TickNum({
value,
format = nf2.format,
className,
}: {
value: number;
format?: (n: number) => string;
className?: string;
}) {
const reduced = useReducedMotion();
const prev = React.useRef(value);
const [dir, setDir] = React.useState<"up" | "down" | null>(null);
React.useEffect(() => {
if (reduced || value === prev.current) {
prev.current = value;
return;
}
setDir(value > prev.current ? "up" : "down");
prev.current = value;
const t = setTimeout(() => setDir(null), 600);
return () => clearTimeout(t);
}, [value, reduced]);
return (
<span
className={cn(
"tabular-nums transition-colors duration-500",
dir === "up" && "text-market-up",
dir === "down" && "text-market-down",
className
)}
>
{format(value)}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Option chain */
/* ------------------------------------------------------------------ */
export interface OptionOrder {
side: "call" | "put";
action: "buy" | "sell";
strike: number;
price: number;
}
export interface OptionChainProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "rows"> {
rows: OptionChainRow[];
spot: number;
/** Lot size shown in the depth panel. Default 75 (NIFTY). */
lotSize?: number;
expiries?: string[];
expiry?: string;
onExpiryChange?: (expiry: string) => void;
/** Start with Greek columns instead of OI/volume. */
defaultShowGreeks?: boolean;
/** Fewer columns for narrow containers. */
compact?: boolean;
onOrder?: (order: OptionOrder) => void;
ref?: React.Ref<HTMLDivElement>;
}
interface ColumnDef {
key: string;
label: string;
cell: (q: OptionQuote) => React.ReactNode;
}
export function OptionChain({
rows,
spot,
lotSize = 75,
expiries,
expiry,
onExpiryChange,
defaultShowGreeks = false,
compact = false,
onOrder,
className,
ref,
...rest
}: OptionChainProps) {
const reduced = useReducedMotion();
const [showGreeks, setShowGreeks] = React.useState(defaultShowGreeks);
const [expanded, setExpanded] = React.useState<number | null>(null);
const atmStrike = React.useMemo(() => {
let best = rows[0]?.strike ?? 0;
for (const r of rows)
if (Math.abs(r.strike - spot) < Math.abs(best - spot)) best = r.strike;
return best;
}, [rows, spot]);
const maxOi = React.useMemo(
() => Math.max(1, ...rows.flatMap((r) => [r.call.oi, r.put.oi])),
[rows]
);
const columns: ColumnDef[] = React.useMemo(() => {
if (compact)
return [
{ key: "oi", label: "OI", cell: (q) => compactOi(q.oi) },
{ key: "ltp", label: "LTP", cell: (q) => <TickNum value={q.ltp} /> },
];
if (showGreeks)
return [
{ key: "delta", label: "Δ", cell: (q) => <TickNum value={q.delta} format={(n) => n.toFixed(2)} /> },
{ key: "gamma", label: "Γ", cell: (q) => <TickNum value={q.gamma} format={(n) => n.toFixed(4)} /> },
{ key: "theta", label: "Θ", cell: (q) => <TickNum value={q.theta} format={(n) => n.toFixed(2)} /> },
{ key: "vega", label: "V", cell: (q) => <TickNum value={q.vega} format={(n) => n.toFixed(2)} /> },
{ key: "ltp", label: "LTP", cell: (q) => <TickNum value={q.ltp} /> },
];
return [
{ key: "oi", label: "OI", cell: (q) => compactOi(q.oi) },
{
key: "chgOi",
label: "Chg OI",
cell: (q) => (
<span className={q.chgOi >= 0 ? "text-market-up" : "text-market-down"}>
{q.chgOi >= 0 ? "+" : ""}
{compactOi(q.chgOi)}
</span>
),
},
{ key: "volume", label: "Vol", cell: (q) => compactOi(q.volume) },
{ key: "iv", label: "IV", cell: (q) => `${(q.iv * 100).toFixed(1)}%` },
{ key: "ltp", label: "LTP", cell: (q) => <TickNum value={q.ltp} /> },
{
key: "chg",
label: "Chg%",
cell: (q) => (
<TickNum
value={q.chg}
format={(n) => `${n >= 0 ? "+" : ""}${n.toFixed(1)}%`}
className={q.chg >= 0 ? "text-market-up" : "text-market-down"}
/>
),
},
];
}, [compact, showGreeks]);
const toggleRow = (strike: number) =>
setExpanded((e) => (e === strike ? null : strike));
const oiBar = (q: OptionQuote, side: "call" | "put") => ({
backgroundImage: `linear-gradient(to ${side === "call" ? "left" : "right"}, ${
side === "call" ? "var(--bid)" : "var(--ask)"
} ${Math.round((q.oi / maxOi) * 100)}%, transparent ${Math.round((q.oi / maxOi) * 100)}%)`,
});
return (
<div
ref={ref}
data-slot="option-chain"
className={cn(
"w-full rounded-xl border border-border bg-card text-sm",
className
)}
{...rest}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
Spot
</span>
<TickNum value={spot} className="text-sm font-semibold" />
</div>
{expiries && expiries.length > 0 ? (
<div
role="tablist"
aria-label="Expiry"
className="flex items-center gap-1 rounded-lg bg-muted p-0.5"
>
{expiries.map((e) => (
<button
key={e}
role="tab"
aria-selected={e === expiry}
onClick={() => onExpiryChange?.(e)}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors duration-200",
e === expiry
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{e}
</button>
))}
</div>
) : null}
{!compact ? (
<button
type="button"
aria-pressed={showGreeks}
onClick={() => setShowGreeks((g) => !g)}
className={cn(
"rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors duration-200",
showGreeks
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
Greeks
</button>
) : null}
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full min-w-max border-collapse text-right text-[13px]">
<thead>
<tr className="border-b border-border text-[11px] uppercase tracking-wide text-muted-foreground">
<th
colSpan={columns.length}
className="px-3 pb-1 pt-2 text-center font-medium text-bid"
>
Calls
</th>
<th className="px-3 pb-1 pt-2 text-center font-medium">Strike</th>
<th
colSpan={columns.length}
className="px-3 pb-1 pt-2 text-center font-medium text-ask"
>
Puts
</th>
</tr>
<tr className="border-b border-border text-[11px] text-muted-foreground">
{columns.map((c) => (
<th key={`c-${c.key}`} className="px-3 py-1.5 font-medium">
{c.label}
</th>
))}
<th className="px-3 py-1.5 text-center font-medium">₹</th>
{[...columns].reverse().map((c) => (
<th key={`p-${c.key}`} className="px-3 py-1.5 font-medium">
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isAtm = row.strike === atmStrike;
const callItm = row.strike < spot;
const putItm = row.strike > spot;
const isOpen = expanded === row.strike;
return (
<React.Fragment key={row.strike}>
<tr
tabIndex={0}
role="button"
aria-expanded={isOpen}
aria-label={`Strike ${row.strike}, ${isAtm ? "at the money, " : ""}call ${nf2.format(row.call.ltp)}, put ${nf2.format(row.put.ltp)}`}
onClick={() => toggleRow(row.strike)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleRow(row.strike);
}
}}
className={cn(
"cursor-pointer border-b border-border/60 transition-colors duration-150",
isAtm
? "bg-accent/80 font-medium"
: "hover:bg-muted/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
)}
>
{columns.map((c, i) => (
<td
key={`c-${c.key}`}
className={cn(
"relative px-3 py-1.5",
callItm && "bg-bid/5"
)}
>
{i === 0 && !compact ? (
<span
aria-hidden="true"
className="absolute inset-y-1 right-0 left-0 opacity-15"
style={oiBar(row.call, "call")}
/>
) : null}
<span className="relative">{c.cell(row.call)}</span>
</td>
))}
<td
className={cn(
"px-3 py-1.5 text-center font-semibold tabular-nums",
isAtm && "text-foreground"
)}
>
{nf0.format(row.strike)}
{isAtm ? (
<span className="ml-1 rounded bg-primary px-1 py-px text-[9px] font-semibold uppercase text-primary-foreground">
ATM
</span>
) : null}
</td>
{[...columns].reverse().map((c, i) => (
<td
key={`p-${c.key}`}
className={cn(
"relative px-3 py-1.5",
putItm && "bg-ask/5"
)}
>
{i === columns.length - 1 && !compact ? (
<span
aria-hidden="true"
className="absolute inset-y-1 left-0 right-0 opacity-15"
style={oiBar(row.put, "put")}
/>
) : null}
<span className="relative">{c.cell(row.put)}</span>
</td>
))}
</tr>
{isOpen ? (
<tr className="border-b border-border/60 bg-muted/30">
<td colSpan={columns.length * 2 + 1} className="p-0">
<DepthPanel
row={row}
lotSize={lotSize}
reduced={reduced}
onOrder={onOrder}
/>
</td>
</tr>
) : null}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
<p className="border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
Lot size {lotSize} · click a strike for depth and orders · simulated
data
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Depth + order panel (expands in context under the strike row) */
/* ------------------------------------------------------------------ */
function DepthPanel({
row,
lotSize,
reduced,
onOrder,
}: {
row: OptionChainRow;
lotSize: number;
reduced: boolean;
onOrder?: (order: OptionOrder) => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
useGSAP(
() => {
if (!ref.current) return;
if (reduced) {
gsap.set(ref.current, { height: "auto", opacity: 1 });
return;
}
gsap.fromTo(
ref.current,
{ height: 0, opacity: 0 },
{
height: "auto",
opacity: 1,
duration: 0.35,
ease: "power3.out",
clearProps: "height",
}
);
},
{ scope: ref }
);
const side = (label: "call" | "put", q: OptionQuote) => (
<div className="flex-1 rounded-lg border border-border bg-card p-3">
<div className="mb-2 flex items-center justify-between">
<span
className={cn(
"text-xs font-semibold uppercase tracking-wide",
label === "call" ? "text-bid" : "text-ask"
)}
>
{label} {nf0.format(row.strike)}
</span>
<span className="text-xs text-muted-foreground">
Δ {q.delta.toFixed(2)} · Θ {q.theta.toFixed(2)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-center text-[13px] tabular-nums">
<div className="rounded-md bg-bid/10 px-2 py-1.5">
<p className="text-[10px] uppercase text-muted-foreground">
Bid × {q.bidQty}
</p>
<p className="font-medium text-bid">{nf2.format(q.bid)}</p>
</div>
<div className="rounded-md bg-ask/10 px-2 py-1.5">
<p className="text-[10px] uppercase text-muted-foreground">
Ask × {q.askQty}
</p>
<p className="font-medium text-ask">{nf2.format(q.ask)}</p>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() =>
onOrder?.({ side: label, action: "buy", strike: row.strike, price: q.ask })
}
className="rounded-md bg-market-up px-2 py-1.5 text-xs font-semibold text-white transition-transform duration-100 active:scale-97 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Buy ({lotSize})
</button>
<button
type="button"
onClick={() =>
onOrder?.({ side: label, action: "sell", strike: row.strike, price: q.bid })
}
className="rounded-md bg-market-down px-2 py-1.5 text-xs font-semibold text-white transition-transform duration-100 active:scale-97 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Sell ({lotSize})
</button>
</div>
</div>
);
return (
<div ref={ref} className="overflow-hidden">
<div className="flex flex-col gap-3 p-3 sm:flex-row">
{side("call", row.call)}
{side("put", row.put)}
</div>
</div>
);
}