StatusBar
Display environment, market clocks, and account readings in your layout.
import { StatusBar, StatusBarEnvironmentBadge, StatusBarClocks, StatusBarClockReadout, StatusBarUser } from "@/components/ui/status-bar"
export default function StatusBarDemo() {
return (
<div className="w-xl max-w-full">
<StatusBar data-environment="PRODUCTION">
<StatusBarEnvironmentBadge label="PRODUCTION" tone="destructive" />
<div className="min-w-4 flex-1" data-status-slot="center" />
<StatusBarClocks>
<StatusBarClockReadout label="New York" zone="America/New_York" />
</StatusBarClocks>
<StatusBarUser user="jdoe" />
</StatusBar>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/status-bar#v2.0.0
npx shadcn@latest add tradecn/ui/status-bar#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/status-bar#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/status-bar#v2.0.0
-
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 { ComponentProps, 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" 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 extends Omit<ComponentProps<"div">, "children"> { children: ReactNode } export interface StatusBarEnvironmentBadgeProps extends Omit<ComponentProps<"span">, "children">, StatusBarEnvironment { /** Screen-reader prefix and default title. Default "Environment". */ prefix?: string } export interface StatusBarClocksProps extends Omit<ComponentProps<"div">, "children"> { children: ReactNode } export interface StatusBarClockReadoutProps extends Omit<ComponentProps<"span">, "children">, StatusBarClock { /** The shared one-second clock unless given another. */ source?: Clock } export interface StatusBarUserProps extends Omit<ComponentProps<"span">, "children"> { user: string /** Screen-reader prefix and default title prefix. Default "Signed in as". */ prefix?: string } /** * The environment badge's classes per tone: the tone's tint behind foreground text. The label is always the word; the * tone is the hint. A tone's own color as text on its tint can drop below 4.5 to 1 in the light themes. The tint is a * background image, so a badge style's own `dark:` background can't paint over it. */ export const STATUS_TONE_CLASS: Record<StatusTone, string> = { up: "text-foreground bg-[linear-gradient(var(--up-soft),var(--up-soft))]", down: "text-foreground bg-[linear-gradient(var(--down-soft),var(--down-soft))]", flat: "text-foreground bg-[linear-gradient(var(--flat-soft),var(--flat-soft))]", stale: "text-foreground bg-[linear-gradient(var(--stale-soft),var(--stale-soft))]", expiring: "text-foreground bg-[linear-gradient(var(--expiring-soft),var(--expiring-soft))]", primary: "text-foreground bg-[linear-gradient(color-mix(in_oklab,var(--primary)_15%,transparent),color-mix(in_oklab,var(--primary)_15%,transparent))]", destructive: "text-foreground bg-[linear-gradient(color-mix(in_oklab,var(--destructive)_15%,transparent),color-mix(in_oklab,var(--destructive)_15%,transparent))]", } 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 } /** A static container. Each clock readout owns its subscription. */ export function StatusBar({ className, children, ...props }: StatusBarProps) { return ( <div role="group" aria-label={DEFAULT_STATUS_BAR_LABELS.title} data-slot="tradecn-status-bar" {...props} 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)}> {children} </div> ) } export function StatusBarEnvironmentBadge({ label, tone, prefix = DEFAULT_STATUS_BAR_LABELS.environment, className, ...props }: StatusBarEnvironmentBadgeProps) { return ( <Badge variant="outline" data-status-environment={label} data-tone={tone} title={prefix} {...props} className={cn("h-5 shrink-0 border-transparent px-1.5 text-xs font-semibold tracking-wide", tone && STATUS_TONE_CLASS[tone], className)}> <span className="sr-only">{prefix}: </span> {label} </Badge> ) } export function StatusBarClocks({ className, children, ...props }: StatusBarClocksProps) { return ( <div role="group" aria-label={DEFAULT_STATUS_BAR_LABELS.clocks} data-status-slot="clocks" {...props} className={cn("flex shrink-0 items-center gap-3", className)}> {children} </div> ) } export function StatusBarClockReadout({ label, zone, seconds, hourCycle, source, className, ...props }: StatusBarClockReadoutProps) { const now = useNow(source) return ( <span data-status-clock={label} title={zone} {...props} className={cn("flex items-baseline gap-1", className)}> <span className="text-muted-foreground">{label}</span> <time className={NUMERIC_CLASS} dateTime={new Date(now).toISOString()} data-status-time> {formatClock(now, { label, zone, seconds, hourCycle })} </time> </span> ) } export function StatusBarUser({ user, prefix = DEFAULT_STATUS_BAR_LABELS.user, className, ...props }: StatusBarUserProps) { return ( <span data-status-user={user} title={`${prefix} ${user}`} {...props} className={cn("shrink-0 truncate", className)}> <span className="sr-only">{prefix} </span> {user} </span> ) }hooks/use-clock.ts import { useCallback, 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() // Called through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribe = useCallback((cb: () => void) => c.subscribe(cb), [c]) const now = useCallback(() => c.now(), [c]) return useSyncExternalStore(subscribe, now, 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 /** Read the source for an event timestamp without changing the tick snapshot or starting a timer. */ sample?(): number } /** One interval shared by every subscriber; it runs only while someone is listening. An interval that is not a positive number of milliseconds a timer can hold, up to 2^31 − 1, ticks once a second. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { // A zero, negative, NaN, or overlong interval (a timer holds 2^31 − 1 ms and fires at once past it) would run as // fast as the timer allows and re-render every subscriber each time. const every = intervalMs > 0 && intervalMs <= 2_147_483_647 ? intervalMs : 1000 let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, sample: source, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, every) } 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 /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } 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>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 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) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN 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) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * 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 = stripGrouping(s, { decimals: printsDecimals(c) }) 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 // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 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; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).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, so digits and the dash take one width; tails still lengthen a quote. */ 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 * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * 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" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** 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 = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : 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-soft: oklch(0.46 0.19 40 / 18%); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat-soft: oklch(0.55 0 0 / 14%); --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-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down-soft: oklch(0.66 0.19 40 / 18%); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat-soft: oklch(0.7 0 0 / 14%); --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-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, StatusBarEnvironmentBadge, StatusBarClocks, StatusBarClockReadout, StatusBarUser } from "@/components/ui/status-bar"
export default function StatusBarDemo() {
return (
<div className="w-xl max-w-full">
<StatusBar data-environment="PRODUCTION">
<StatusBarEnvironmentBadge label="PRODUCTION" tone="destructive" />
<div className="min-w-4 flex-1" data-status-slot="center" />
<StatusBarClocks>
<StatusBarClockReadout label="New York" zone="America/New_York" />
</StatusBarClocks>
<StatusBarUser user="jdoe" />
</StatusBar>
</div>
)
}
Place the bar at the bottom of your layout. The spacer pushes the clocks and user to the right, and the bar wraps when space runs out.
Composition
Use the following composition to build a StatusBar:
StatusBar
├── StatusBarEnvironmentBadge
├── Your content and spacer
├── StatusBarClocks
│ └── StatusBarClockReadout
└── StatusBarUser
Arrange or omit the parts as needed. Each reading also works outside the bar. Only StatusBarClockReadout subscribes to time updates. Render the clocks on the client: the shared clock advances only while something in a browser subscribes, so a server-rendered reading prints a stale time in the runtime's own locale and mismatches on hydration.
Account Card
Use the same readings in a grid, with the user first and the environment after the clocks.
import { useState } from "react"
import { StatusBar, StatusBarClocks, StatusBarClockReadout, StatusBarEnvironmentBadge, StatusBarUser } from "@/components/ui/status-bar"
export default function StatusBarCardDemo() {
const [user, setUser] = useState<string | null>("jdoe")
return (
<StatusBar aria-label="London desk" className="grid w-80 max-w-full gap-3 rounded border p-4 whitespace-normal">
<div className="flex flex-wrap items-center justify-between gap-2">
{user ? <StatusBarUser user={user} /> : <span>Session ended</span>}
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => setUser(user ? null : "jdoe")}>
{user ? "Sign out" : "Sign in"}
</button>
</div>
<StatusBarClocks aria-label="Market hours" className="grid">
<StatusBarClockReadout label="London" zone="Europe/London" seconds={false} className="justify-between" />
<StatusBarClockReadout label="New York" zone="America/New_York" seconds={false} className="justify-between" />
</StatusBarClocks>
<div className="flex flex-wrap items-center justify-between gap-2">
<span>{user ? "Order entry enabled" : "Sign in to trade"}</span>
<StatusBarEnvironmentBadge label="PRODUCTION" tone="destructive" />
</div>
</StatusBar>
)
}
Feed and frame health
Install feed-health and perf-monitor, then save the complete PerfMonitor Usage example as perf-monitor.tsx beside this example, outside components/ui so it cannot overwrite the installed component, to supply FrameReadings.
Choose Receive message to refresh the feed timestamp. Its default thresholds mark it aging after two seconds and stale after ten. The compact feed keeps its tier word available to screen readers and in its tooltip.
The frame monitor measures while mounted. Its bounded width lets readings wrap within the bar on narrow screens.
import { useState } from "react"
import { FeedHealth, FeedHealthItem, FeedHealthIndicator, FeedHealthTier, FeedAge, FeedHealthLane, FeedHealthTooltipTrigger, FeedHealthTooltipContent, FeedHealthDetails, FeedHealthAnnouncer, type FeedDescriptor } from "@/components/ui/feed-health"
import { Tooltip } from "@/components/ui/tooltip"
import { PerfMonitor } from "@/components/ui/perf-monitor"
import { FrameReadings } from "./perf-monitor"
import { StatusBar, StatusBarEnvironmentBadge, StatusBarUser } from "@/components/ui/status-bar"
export default function StatusBarSlotsDemo() {
const [lastMessageAt, setLastMessageAt] = useState(Date.now)
const feed: FeedDescriptor = { id: "md", label: "Market data", state: "connected", lane: "coalesced", lastMessageAt, dropped: 0 }
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 data-environment="UAT">
<StatusBarEnvironmentBadge label="UAT" tone="stale" />
<div className="flex shrink-0 items-center gap-2" data-status-slot="left">
<FeedHealth feeds={[feed]}><FeedHealthItem feed={feed}>
<Tooltip>
<FeedHealthTooltipTrigger>
<span className="font-medium">{feed.label}</span><FeedHealthIndicator className="order-first" />
<FeedHealthTier className="sr-only" />
<FeedAge feed={feed} /><FeedHealthLane />
</FeedHealthTooltipTrigger>
<FeedHealthTooltipContent><FeedHealthDetails /></FeedHealthTooltipContent>
</Tooltip>
</FeedHealthItem><FeedHealthAnnouncer /></FeedHealth>
</div>
<div className="min-w-4 flex-1" data-status-slot="center" />
<StatusBarUser user="jdoe" />
<div className="flex shrink-0 items-center gap-2" data-status-slot="right">
<PerfMonitor className="w-56"><FrameReadings /></PerfMonitor>
</div>
</StatusBar>
</div>
</>
)
}
API Reference
StatusBar props
StatusBar forwards native div props, events and refs. children is required and may be conditional content, including null or false.
| Prop | Type | Default |
|---|---|---|
children |
ReactNode |
Required |
aria-label |
string |
"Status" |
className |
string |
— |
The root has role="group" and data-slot="tradecn-status-bar". Use aria-label or aria-labelledby to name it. It creates no live region and does not announce each clock tick. Native controls supplied as children retain their usual keyboard behavior.
The environment is a safety feature
StatusBarEnvironmentBadge displays the environment as a word and a tone. It forwards native span props and refs to the installed outline Badge.
| Prop | Type | Default |
|---|---|---|
label |
string |
Required |
tone |
StatusTone |
— |
prefix |
string |
"Environment" |
The prefix appears in screen-reader text and supplies the default title. The badge carries data-status-environment and, when supplied, data-tone. Set data-environment on the root yourself if your selectors use it, as in Usage.
StatusTone is "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive". STATUS_TONE_CLASS: Record<StatusTone, string> exports each tone's classes: its tint behind foreground text, since a tone's own color on its tint can fall below 4.5 to 1 in light mode. The tint is a background image, so a badge style's own dark background can't cover it. Omitting the tone adds no status-tone classes.
The StatusBarEnvironment descriptor type retains its label and optional tone fields. Spread it onto StatusBarEnvironmentBadge when storing environment options as data.
The clocks
StatusBarClocks groups your readings. It forwards native div props and refs, requires children, and defaults to role="group", aria-label="Clocks" and data-status-slot="clocks". Supply the collection order and keys yourself; omit the group when your collection is empty.
StatusBarClockReadout forwards native span props and refs:
| Prop | Type | Default |
|---|---|---|
label |
string |
Required |
zone |
string |
Required IANA time zone |
seconds |
boolean |
true |
hourCycle |
"h23" | "h12" |
"h23" |
source |
Clock |
Shared one-second clock |
Each readout shows its label beside a <time> element with lining, tabular figures and the instant's ISO timestamp in dateTime. The wrapper's default title is the zone. Formatting uses the runtime's default locale; an unknown zone prints NULL_TOKEN (–).
Every readout subscribes locally through useNow. Three readings using the default source share one timer, which stops after its last subscriber leaves. Setting seconds={false} changes the visible format, while the ISO timestamp still updates on the source cadence, once a second by default. A bar without clock readings creates no clock subscriptions.
Pass source to each readout to use a custom clock, and keep the object stable between renders: the readout resubscribes when source changes, and a new object built each render resubscribes each render. Its Clock interface, from @/lib/clock beside createClock, has now(): number, returning the last tick's epoch milliseconds, subscribe(callback: () => void): () => void, returning cleanup, and an optional sample?() that reads the source for an event timestamp without changing the tick snapshot or starting a timer. Keep now() stable between ticks and supply valid timestamps within JavaScript's date range.
The StatusBarClock descriptor type retains label, zone, seconds and hourCycle. When mapping descriptors, use a stable key such as a unique label/zone pair and spread each descriptor onto StatusBarClockReadout.
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 construction fails, including an unknown zone. |
formatClock(ms: number, clock: StatusBarClock) |
string |
Formats epoch milliseconds using the descriptor'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
StatusBarUser prints a required user: string with prefix, defaulting to "Signed in as", in screen-reader text and the default title. It forwards native span props and refs and carries data-status-user.
The application supplies session state and decides when to hide the reading. Render it conditionally when the user may be missing.
Labels
Use native aria-label or aria-labelledby on the groups, and prefix on the environment and user readings. Visible environment, clock and user text comes from the corresponding parts.
DEFAULT_STATUS_BAR_LABELS and the StatusBarLabels type remain available:
| Field | Default | Used by |
|---|---|---|
title |
"Status" |
Root's default accessible name |
environment |
"Environment" |
Environment prefix and default title |
user |
"Signed in as" |
User prefix and default title prefix |
clocks |
"Clocks" |
Clock group's default accessible name |
What it does not do
The application owns environment, session and feed state. Content inside the bar owns its own behavior; the bar reads no feed and counts no frames. It does not fix itself to the window.
Tokens
The install adds the soft variants of the up, down, flat, stale, and expiring tokens if you do not already have them, along with the --tradecn-font-sans, --tradecn-font-mono, and --tradecn-font-numeric tokens, their accessible pair, and the hyperlegible remap. The environment badge tints with those; primary and destructive tint with the corresponding shadcn tokens at 15 percent.
Built on shadcn's badge.
This page is docs/status-bar.md at v2.0.0.