StatusBar
Show the environment, market clocks, signed-in user, and your own readouts in a terminal's bottom bar.
import { StatusBar } from "@/components/ui/status-bar"
export default function StatusBarDemo() {
return (
<div className="w-xl max-w-full">
<StatusBar
environment={{ label: "PRODUCTION", tone: "destructive" }}
clocks={[{ label: "New York", zone: "America/New_York" }]}
user="jdoe"
/>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/status-bar#v1.4.13
npx shadcn@latest add tradecn/ui/status-bar#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/status-bar#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/status-bar#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badgenpx shadcn@latest add badgeyarn dlx shadcn@latest add badgebunx --bun shadcn@latest add badge -
Copy the files into your project:
components/ui/ status-bar.tsx import { cn } from "cn" import type { ReactNode } from "react" import { Badge } from "@/components/ui/badge" import { useNow } from "@/hooks/use-clock" import type { Clock } from "@/lib/clock" import { NULL_TOKEN, NUMERIC_CLASS } from "@/lib/format" // The strip at the bottom of every terminal: which environment this is, what time it is where the // markets are, who is signed in, and whatever the consumer puts in the slots between, a feed health // strip and a frame monitor as a rule. Every string is the consumer's. The environment badge is a // safety feature: production reads at a glance, in a word and a tone. The clocks tick on the shared // one-second clock, so a bar with three of them costs one timer. export type StatusTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export interface StatusBarEnvironment { /** The word: "PRODUCTION", "UAT", "DEV". Printed as is. */ label: string tone?: StatusTone } export interface StatusBarClock { /** "New York", "London", "Tokyo". */ label: string /** An IANA zone: "America/New_York". */ zone: string /** Show seconds. Default true. */ seconds?: boolean /** Default h23. */ hourCycle?: "h23" | "h12" } export interface StatusBarLabels { /** The bar's accessible name. */ title: string environment: string user: string clocks: string } export const DEFAULT_STATUS_BAR_LABELS: StatusBarLabels = { title: "Status", environment: "Environment", user: "Signed in as", clocks: "Clocks", } export interface StatusBarProps { environment?: StatusBarEnvironment clocks?: StatusBarClock[] /** The signed-in user, as a string to print. */ user?: string /** Your children, left of center. */ left?: ReactNode /** Your children, in the middle. */ center?: ReactNode /** Your children, right of the clocks and the user. */ right?: ReactNode /** The shared one-second clock unless given another. */ clock?: Clock labels?: Partial<StatusBarLabels> className?: string } /** The environment badge's classes per tone. The label is always the word; the tone is the hint. */ export const STATUS_TONE_CLASS: Record<StatusTone, string> = { up: "text-up bg-up-soft", down: "text-down bg-down-soft", flat: "text-flat bg-flat-soft", stale: "text-stale bg-stale-soft", expiring: "text-expiring bg-expiring-soft", primary: "text-primary bg-primary/15", destructive: "text-destructive bg-destructive/15", } const formats = new Map<string, Intl.DateTimeFormat | null>() /** A formatter for a zone, made once; null when the zone is not one the runtime knows. */ export function clockFormat(zone: string, seconds = true, hourCycle: "h23" | "h12" = "h23"): Intl.DateTimeFormat | null { const key = `${zone}|${seconds}|${hourCycle}` if (!formats.has(key)) { try { formats.set(key, new Intl.DateTimeFormat(undefined, { timeZone: zone, hour: "2-digit", minute: "2-digit", second: seconds ? "2-digit" : undefined, hourCycle })) } catch { formats.set(key, null) } } return formats.get(key) ?? null } /** The time in a zone, or the null token when the zone is not one the runtime knows. */ export function formatClock(ms: number, clock: StatusBarClock): string { const format = clockFormat(clock.zone, clock.seconds ?? true, clock.hourCycle ?? "h23") return format ? format.format(ms) : NULL_TOKEN } function ClockReadout({ clock, source }: { clock: StatusBarClock; source?: Clock }) { const now = useNow(source) return ( <span className="flex items-baseline gap-1" data-status-clock={clock.label} title={clock.zone}> <span className="text-muted-foreground">{clock.label}</span> <time className={NUMERIC_CLASS} dateTime={new Date(now).toISOString()} data-status-time> {formatClock(now, clock)} </time> </span> ) } export function StatusBar({ environment, clocks, user, left, center, right, clock, labels: labelsProp, className }: StatusBarProps) { const labels = { ...DEFAULT_STATUS_BAR_LABELS, ...labelsProp } return ( // Nothing in a slot wraps or shrinks under its neighbors: when the bar is narrower than what is in it, the slots wrap onto another line whole. <div role="group" aria-label={labels.title} data-slot="tradecn-status-bar" data-environment={environment?.label} className={cn("flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border bg-background px-2 py-0.5 text-xs whitespace-nowrap text-foreground lining-nums tabular-nums", className)}> {environment && ( <Badge variant="outline" className={cn("h-5 shrink-0 border-transparent px-1.5 text-xs font-semibold tracking-wide", environment.tone && STATUS_TONE_CLASS[environment.tone])} data-status-environment={environment.label} data-tone={environment.tone} title={labels.environment}> <span className="sr-only">{labels.environment}: </span> {environment.label} </Badge> )} {left !== undefined && <div className="flex shrink-0 items-center gap-2" data-status-slot="left">{left}</div>} <div className="flex min-w-4 flex-1 items-center justify-center gap-2" data-status-slot="center"> {center} </div> {clocks && clocks.length > 0 && ( <div className="flex shrink-0 items-center gap-3" role="group" aria-label={labels.clocks} data-status-slot="clocks"> {clocks.map((c) => ( <ClockReadout key={`${c.label}|${c.zone}`} clock={c} source={clock} /> ))} </div> )} {user && ( <span className="shrink-0 truncate" data-status-user={user} title={`${labels.user} ${user}`}> <span className="sr-only">{labels.user} </span> {user} </span> )} {right !== undefined && <div className="flex shrink-0 items-center gap-2" data-status-slot="right">{right}</div>} </div> ) }hooks/use-clock.ts import { useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() return useSyncExternalStore(c.subscribe, c.now, c.now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number } /** One interval shared by every subscriber; it runs only while someone is listening. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, intervalMs) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, whose dash, ticks, and tail must line up down a column. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * 99-16+ over 99-17 keeps its dash and its tail in the same place; every other price, and every quote in * another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --stale-soft: oklch(0.52 0.13 75 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { StatusBar } from "@/components/ui/status-bar"
function TerminalStatus() {
return (
<div className="w-xl max-w-full">
<StatusBar
environment={{ label: "PRODUCTION", tone: "destructive" }}
clocks={[{ label: "New York", zone: "America/New_York" }]}
user="jdoe"
/>
</div>
)
}
The application supplies the environment and user text. The clock shows the current time in New York and ticks once a second. This example bounds the bar's width; place it at the bottom of your own layout. The bar wraps when space runs out.
Composition
The order is environment badge, left, center, clocks, user, right. The center wrapper always remains as a flexible spacer, even without content. The left and right wrappers disappear only when their props are undefined; passing null or false leaves an empty wrapper.
Feed and frame health
The bar imports none of the items in its slots. Install feed-health and perf-monitor separately for this example. A supplied feed goes on the left and actual frame measurements on the right. The monitor's bounded width lets its readouts wrap within the slot on narrow screens.
Choose Receive message to update the sample feed's timestamp. With no further messages, its age increases; the default thresholds mark it aging after two seconds and stale after ten. Compact mode hides the tier badge; the feed's tooltip reports the tier. The monitor continues measuring while mounted. There is no demo publisher or load generator.
import { useState } from "react"
import { FeedHealth } from "@/components/ui/feed-health"
import { PerfMonitor } from "@/components/ui/perf-monitor"
import { StatusBar } from "@/components/ui/status-bar"
export default function StatusBarSlotsDemo() {
const [lastMessageAt, setLastMessageAt] = useState(Date.now)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setLastMessageAt(Date.now())}>Receive message</button>
</div>
<div className="w-3xl max-w-full">
<StatusBar
environment={{ label: "UAT", tone: "stale" }}
user="jdoe"
left={<FeedHealth compact feeds={[{ id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt, dropped: 0 }]} />}
right={<PerfMonitor compact className="w-56" />}
/>
</div>
</>
)
}
API Reference
StatusBar props
All props are optional. StatusBarProps accepts the following inputs; it does not forward arbitrary HTML attributes or accept a children prop.
| Prop | Type | Default | Purpose |
|---|---|---|---|
environment |
StatusBarEnvironment |
Omitted | Environment badge, first on the bar. |
clocks |
StatusBarClock[] |
Omitted | Clock readouts in array order; an empty array hides the group. |
user |
string |
Omitted | Signed-in user text; an empty string hides it. |
left |
ReactNode |
undefined |
Content before the center spacer. |
center |
ReactNode |
undefined |
Centered content in the flexible spacer. |
right |
ReactNode |
undefined |
Content after the user. |
clock |
Clock |
Shared one-second clock | Time source for every clock readout. |
labels |
Partial<StatusBarLabels> |
DEFAULT_STATUS_BAR_LABELS |
Overrides for the built-in labels. |
className |
string |
Omitted | Classes merged onto the root. |
The root is a div with role="group", named by labels.title, and data-slot="tradecn-status-bar". It does not create a live region or announce each clock tick.
The environment is a safety feature
The word identifies the environment; its tone helps production stand out at a glance. StatusBarEnvironment has two fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
label |
string |
Required | Text printed as given, such as PRODUCTION, UAT, or DEV. |
tone |
StatusTone |
Omitted | Badge colors; no status-tone classes are added when omitted. |
StatusTone is "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive". STATUS_TONE_CLASS: Record<StatusTone, string> exports the text and background classes for each tone.
The badge uses the outline variant, with labels.environment in its title and a screen-reader prefix such as Environment: . The root carries data-environment; the badge carries data-status-environment and, when supplied, data-tone.
The clocks
Each entry is a StatusBarClock:
| Field | Type | Default | Purpose |
|---|---|---|---|
label |
string |
Required | Visible label beside the time, such as New York. |
zone |
string |
Required | IANA time zone, such as America/New_York. |
seconds |
boolean |
true |
Include seconds in the displayed time. |
hourCycle |
"h23" | "h12" |
"h23" |
Use a 24-hour or 12-hour clock. |
Every readout subscribes to the shared one-second clock from countdown's lib/clock.ts, so three clocks use one timer. The timer runs while it has subscribers. Setting seconds: false on a clock entry changes formatting, not the tick rate. No clock readouts means no subscriptions from the bar.
The clock prop replaces that source. Its Clock interface has now(): number, returning the last tick's epoch milliseconds, and subscribe(callback: () => void): () => void, returning an unsubscribe function. Keep now() stable between ticks and supply a valid timestamp within JavaScript's date range.
The nonempty clocks group is named by labels.clocks. Each time uses a <time> element with lining, tabular figures and the instant's ISO timestamp in dateTime; its wrapper's title contains the zone. Formatting uses the runtime's default locale. An unknown zone prints NULL_TOKEN (–). Keep each label/zone pair unique: the pair is the readout's React key.
Clock helpers
Both helpers are exported from @/components/ui/status-bar.
| Helper | Returns | Behavior |
|---|---|---|
clockFormat(zone: string, seconds = true, hourCycle: "h23" | "h12" = "h23") |
Intl.DateTimeFormat | null |
Caches the formatter by zone, seconds, and hour cycle. Caches null if formatter construction fails, including an unknown zone. |
formatClock(ms: number, clock: StatusBarClock) |
string |
Formats epoch milliseconds using the entry's zone and options; label does not affect the result. Returns NULL_TOKEN when no formatter is available. |
The unknown-zone fallback does not validate timestamps. formatClock can throw for an invalid timestamp with a valid zone, and a readout's ISO dateTime requires a valid timestamp regardless of zone.
The user
user is printed as given, with labels.user as a screen-reader prefix and in the title, for example Signed in as jdoe. The value comes from your session; the bar performs no session lookup.
Labels
labels overrides these fields of StatusBarLabels. Omitted fields retain their values from DEFAULT_STATUS_BAR_LABELS; environment, clock, user, and slot content come from their own props.
| Field | Type | Default | Used for |
|---|---|---|---|
title |
string |
"Status" |
Bar's accessible name. |
environment |
string |
"Environment" |
Badge's title and screen-reader prefix. |
user |
string |
"Signed in as" |
User's title and screen-reader prefix. |
clocks |
string |
"Clocks" |
Clocks group's accessible name. |
What it does not do
Your application owns environment, session, and feed state; the bar displays the props you supply. Items in its slots own their own behavior. It reads no feed and counts no frames. It does not fix itself to the window; place it at the bottom of your layout.
Tokens
The install adds the up, down, flat, stale, and expiring tokens with their soft variants if you do not already have them. The environment badge uses those pairs; primary and destructive use the corresponding shadcn tokens with a translucent background.
Built on shadcn's badge.
This page is docs/status-bar.md at v1.4.13.