Order Ticket
Fintech & dataAdvanced order ticket: buy/sell recolors the whole ticket, order types reveal only the fields they need, margin and charges roll live, over-margin blocks, and placement plays a partial fill.
NIFTY 24800 CE147.10 / 147.60
Margin required₹1,547
Available ₹25,000
- Brokerage
- ₹20
- STT
- ₹7
- Exchange + GST + stamp
- ₹16
Install
npx shadcn@latest add https://labs.duku.design/r/order-ticket.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 Order Ticket component (get_component "order-ticket", 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 |
|---|---|
| Toggle buy/sell | Border, action button and margin bar recolor market-up/market-down |
| Switch to stop-limit | Trigger and price fields appear; market disables and pins price to best bid/ask |
| Crank lots past the margin | Margin bar turns destructive, review is blocked with the reason |
| Confirm the order | Placing → 40% partial fill → complete, with a live fill progress bar |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| symbol / lastPrice / bestBid / bestAsk | string / number ×3 | — | The instrument and its quote. |
| availableMargin / marginFactor | number / number | 250000 / 0.14 | Drives the risk-blocked state. |
| lotSize | number | 75 | Units per lot. |
| onPlaced | (order: PlacedOrder) => void | — | After the fill completes. |
Source — registry/default/fintech/order-ticket.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 { Button } from "@/registry/default/ui/button";
import { NumberFlow } from "@/registry/default/fintech/number-flow";
/* ------------------------------------------------------------------ */
/* Model */
/* ------------------------------------------------------------------ */
export type OrderType = "market" | "limit" | "stop" | "stop-limit";
export interface PlacedOrder {
side: "buy" | "sell";
type: OrderType;
qty: number;
price?: number;
triggerPrice?: number;
}
export interface OrderTicketProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
symbol: string;
lastPrice: number;
bestBid: number;
bestAsk: number;
lotSize?: number;
/** Margin available to trade. Drives the risk-blocked state. */
availableMargin?: number;
/** Margin factor per unit of notional. Default 0.14 (index options-ish). */
marginFactor?: number;
onPlaced?: (order: PlacedOrder) => void;
ref?: React.Ref<HTMLDivElement>;
}
const inr = (n: number) =>
new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0 }).format(n);
const nf2 = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Indian-broker conventions: SL = stop-loss limit (trigger + limit price),
// SL-M = stop-loss market (trigger, fills at market).
const TYPES: { key: OrderType; label: string }[] = [
{ key: "market", label: "Market" },
{ key: "limit", label: "Limit" },
{ key: "stop-limit", label: "SL" },
{ key: "stop", label: "SL-M" },
];
type Stage = "form" | "review" | "placing" | "filling" | "done";
/* ------------------------------------------------------------------ */
/* Ticket */
/* ------------------------------------------------------------------ */
/**
* Advanced order ticket: buy/sell recolors the whole ticket, order types
* reveal the fields they need, margin and charges roll live, over-margin
* blocks the flow, and placement plays a partial-fill progress state.
*/
export function OrderTicket({
symbol,
lastPrice,
bestBid,
bestAsk,
lotSize = 75,
availableMargin = 250_000,
marginFactor = 0.14,
onPlaced,
className,
ref,
...rest
}: OrderTicketProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const [side, setSide] = React.useState<"buy" | "sell">("buy");
const [type, setType] = React.useState<OrderType>("limit");
const [lots, setLots] = React.useState(1);
const [price, setPrice] = React.useState(lastPrice.toFixed(2));
const [trigger, setTrigger] = React.useState((lastPrice * 0.995).toFixed(2));
const [chargesOpen, setChargesOpen] = React.useState(false);
const [stage, setStage] = React.useState<Stage>("form");
const [filled, setFilled] = React.useState(0);
const qty = lots * lotSize;
const effPrice =
type === "market" ? (side === "buy" ? bestAsk : bestBid) : parseFloat(price) || 0;
const notional = qty * effPrice;
const margin = Math.round(notional * marginFactor);
const brokerage = 20;
const stt = Math.round(notional * 0.000625);
const other = Math.round(notional * 0.000053) + 15;
const charges = brokerage + stt + other;
const overMargin = margin > availableMargin;
const needsPrice = type === "limit" || type === "stop-limit";
const needsTrigger = type === "stop" || type === "stop-limit";
const chargesRef = React.useRef<HTMLDivElement>(null);
useGSAP(
() => {
if (!chargesRef.current) return;
if (reduced) {
gsap.set(chargesRef.current, { height: chargesOpen ? "auto" : 0, opacity: chargesOpen ? 1 : 0 });
return;
}
gsap.to(chargesRef.current, {
height: chargesOpen ? "auto" : 0,
opacity: chargesOpen ? 1 : 0,
duration: 0.3,
ease: "power3.out",
});
},
{ dependencies: [chargesOpen, reduced] }
);
const place = async () => {
setStage("placing");
await new Promise((r) => setTimeout(r, 900));
setStage("filling");
// partial fill: 40% then complete
setFilled(Math.round(qty * 0.4));
await new Promise((r) => setTimeout(r, 1300));
setFilled(qty);
await new Promise((r) => setTimeout(r, 500));
setStage("done");
onPlaced?.({
side,
type,
qty,
...(needsPrice ? { price: parseFloat(price) } : {}),
...(needsTrigger ? { triggerPrice: parseFloat(trigger) } : {}),
});
};
const reset = () => {
setStage("form");
setFilled(0);
};
const numField = (
label: string,
value: string,
set: (v: string) => void,
disabled = false
) => (
<label className="flex min-w-0 flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
{label}
<input
inputMode="decimal"
value={value}
disabled={disabled}
aria-label={label}
onChange={(e) => set(e.target.value.replace(/[^0-9.]/g, ""))}
className={cn(
"h-9 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 font-mono text-sm tabular-nums text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "opacity-50"
)}
/>
</label>
);
return (
<div
ref={rootRef}
data-slot="order-ticket"
className={cn(
"w-full max-w-sm rounded-2xl border bg-card p-4 transition-colors duration-300",
side === "buy" ? "border-market-up/40" : "border-market-down/40",
className
)}
{...rest}
>
{/* Header */}
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-foreground">{symbol}</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
<span className="text-bid">{nf2.format(bestBid)}</span>
{" / "}
<span className="text-ask">{nf2.format(bestAsk)}</span>
</span>
</div>
{stage === "form" || stage === "review" ? (
<>
{/* Side toggle */}
<div className="mt-3 grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" role="tablist" aria-label="Order side">
{(["buy", "sell"] as const).map((s) => (
<button
key={s}
role="tab"
aria-selected={side === s}
onClick={() => setSide(s)}
className={cn(
"rounded-md py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors duration-200",
side === s
? s === "buy"
? "bg-market-up text-white"
: "bg-market-down text-white"
: "text-muted-foreground hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{s}
</button>
))}
</div>
{/* Order type */}
<div className="mt-2 flex gap-1" role="tablist" aria-label="Order type">
{TYPES.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={type === t.key}
onClick={() => setType(t.key)}
className={cn(
"flex-1 rounded-md px-1 py-1 text-[11px] font-medium transition-colors duration-200",
type === t.key
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{t.label}
</button>
))}
</div>
{/* Qty + prices */}
<div className="mt-3 flex gap-2">
<label className="flex w-24 flex-col gap-1 text-[11px] text-muted-foreground">
Lots ({lotSize}/lot)
<div className="flex h-9 items-stretch overflow-hidden rounded-lg border border-input">
<button
type="button"
aria-label="Decrease lots"
onClick={() => setLots((l) => Math.max(1, l - 1))}
className="w-7 bg-muted text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
−
</button>
<span className="flex flex-1 items-center justify-center font-mono text-sm tabular-nums text-foreground">
{lots}
</span>
<button
type="button"
aria-label="Increase lots"
onClick={() => setLots((l) => Math.min(20, l + 1))}
className="w-7 bg-muted text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
+
</button>
</div>
</label>
{needsTrigger ? numField("Trigger", trigger, setTrigger) : null}
{numField(
type === "market" ? "Price (mkt)" : "Price",
type === "market" ? nf2.format(effPrice) : price,
setPrice,
type === "market"
)}
</div>
{/* Margin */}
<div className="mt-3 flex items-baseline justify-between text-xs">
<span className="text-muted-foreground">Margin required</span>
<span
className={cn(
"font-semibold tabular-nums",
overMargin ? "text-destructive" : "text-foreground"
)}
>
<NumberFlow value={margin} prefix="₹" locale="en-IN" />
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<span
className={cn(
"block h-full rounded-full transition-all duration-500",
overMargin
? "bg-destructive"
: side === "buy"
? "bg-market-up"
: "bg-market-down"
)}
style={{ width: `${Math.min(100, (margin / availableMargin) * 100)}%` }}
/>
</div>
<p className="mt-1 text-[11px] tabular-nums text-muted-foreground">
Available ₹{inr(availableMargin)}
{overMargin ? (
<span className="ml-1 font-medium text-destructive">
— exceeds margin, reduce quantity
</span>
) : null}
</p>
{/* Charges */}
<button
type="button"
aria-expanded={chargesOpen}
onClick={() => setChargesOpen((o) => !o)}
className="mt-2 flex w-full justify-between rounded-md py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span>Charges ≈ ₹{inr(charges)}</span>
<span className={cn("transition-transform duration-300", chargesOpen && "rotate-180")}>▾</span>
</button>
<div ref={chargesRef} className="overflow-hidden" style={{ height: 0, opacity: 0 }}>
<dl className="flex flex-col gap-1 py-1 text-[11px] tabular-nums text-muted-foreground">
<div className="flex justify-between"><dt>Brokerage</dt><dd>₹{brokerage}</dd></div>
<div className="flex justify-between"><dt>STT</dt><dd>₹{inr(stt)}</dd></div>
<div className="flex justify-between"><dt>Exchange + GST + stamp</dt><dd>₹{inr(other)}</dd></div>
</dl>
</div>
{/* Action */}
{stage === "form" ? (
<Button
className={cn(
"mt-3 w-full text-white",
side === "buy"
? "bg-market-up hover:bg-market-up/90"
: "bg-market-down hover:bg-market-down/90"
)}
disabled={overMargin || (needsPrice && !parseFloat(price))}
onClick={() => setStage("review")}
>
Review {side} order
</Button>
) : (
<div className="mt-3 flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
<p className="text-xs text-foreground">
<span className={cn("font-semibold uppercase", side === "buy" ? "text-market-up" : "text-market-down")}>
{side}
</span>{" "}
{qty} {symbol} · {TYPES.find((t) => t.key === type)?.label}
{needsPrice ? ` @ ₹${price}` : " @ market"}
{needsTrigger ? ` · trigger ₹${trigger}` : ""}
</p>
<p className="text-[11px] tabular-nums text-muted-foreground">
Margin ₹{inr(margin)} + charges ₹{inr(charges)}
</p>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setStage("form")}>
Edit
</Button>
<Button
size="sm"
className={cn(
"flex-1 text-white",
side === "buy"
? "bg-market-up hover:bg-market-up/90"
: "bg-market-down hover:bg-market-down/90"
)}
onClick={place}
>
Confirm
</Button>
</div>
</div>
)}
</>
) : (
<FillState
stage={stage}
side={side}
qty={qty}
filled={filled}
symbol={symbol}
onReset={reset}
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Placement / partial fill */
/* ------------------------------------------------------------------ */
function FillState({
stage,
side,
qty,
filled,
symbol,
onReset,
}: {
stage: Stage;
side: "buy" | "sell";
qty: number;
filled: number;
symbol: string;
onReset: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const pct = Math.round((filled / qty) * 100);
useGSAP(
() => {
if (!ref.current) return;
if (reduced) {
gsap.set(ref.current, { opacity: 1, y: 0 });
return;
}
gsap.fromTo(
ref.current,
{ opacity: 0, y: 12, filter: "blur(6px)" },
{ opacity: 1, y: 0, filter: "blur(0px)", duration: 0.4, ease: "power3.out", clearProps: "filter" }
);
},
{ scope: ref }
);
return (
<div ref={ref} className="mt-4 flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm font-medium text-foreground">
{stage === "placing"
? "Placing order…"
: stage === "filling"
? pct < 100
? "Partially filled"
: "Filling…"
: "Order complete"}
</p>
<div
className="h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuemin={0}
aria-valuemax={qty}
aria-valuenow={filled}
aria-label="Fill progress"
>
<span
className={cn(
"block h-full rounded-full transition-all duration-700 ease-out",
side === "buy" ? "bg-market-up" : "bg-market-down"
)}
style={{ width: `${stage === "placing" ? 4 : pct}%` }}
/>
</div>
<p className="text-xs tabular-nums text-muted-foreground">
{filled}/{qty} {symbol}
{stage === "filling" && pct < 100 ? " · working the rest at your limit" : ""}
</p>
{stage === "done" ? (
<Button size="sm" variant="outline" onClick={onReset}>
New order
</Button>
) : null}
</div>
);
}