Awesome Cursor Rules
uilivelinesource: ok · refreshed
Liveline
Real-time React canvas chart with line, multi-series, candlestick, and market-data modes.
npx shadcn add "https://react.tileui.zmorg.cn/r/liveline.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@tile-ui/liveline-core@tile-ui/styles
Files4 · source cached 10/2/2026, 12:45:34 AM
packages/react/src/components/liveline/liveline.tsx
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
SERIES_COLORS,
createLivelineEngine,
resolveSeriesPalettes,
resolveTheme,
type LivelineEngine,
type LivelineEngineConfig,
type LivelineOptions,
type Momentum,
} from '../lib/liveline';
import styles from './liveline.module.scss';
const defaultFormatValue = (value: number) => value.toFixed(2);
const defaultFormatTime = (time: number) => {
const date = new Date(time * 1000);
return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => part.toString().padStart(2, '0')).join(':');
};
export interface LivelineProps extends LivelineOptions, Omit<React.HTMLAttributes<HTMLDivElement>, keyof LivelineOptions | 'children'> {
/** Styles the engine's chart surface; style sizes the complete widget, including controls. */
surfaceStyle?: React.CSSProperties;
surfaceClassName?: string;
}
function setIndicator(bar: HTMLDivElement | null, button: HTMLButtonElement | undefined, setState: React.Dispatch<React.SetStateAction<{ left: number; width: number } | null>>) {
if (!bar || !button) return;
const barRect = bar.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
setState({ left: buttonRect.left - barRect.left, width: buttonRect.width });
}
const Liveline = React.forwardRef<HTMLDivElement, LivelineProps>(
(
{
data,
value,
series: seriesProp,
theme = 'dark',
color = '#3b82f6',
window: windowSecs = 30,
grid = true,
badge = true,
momentum = true,
fill = true,
scrub = true,
loading = false,
paused = false,
emptyText,
exaggerate = false,
degen,
badgeTail = true,
badgeVariant = 'default',
showValue = false,
valueMomentumColor = false,
windows,
onWindowChange,
windowStyle = 'default',
tooltipY = 14,
tooltipOutline = true,
orderbook,
referenceLine,
formatValue = defaultFormatValue,
formatTime = defaultFormatTime,
lerpSpeed = 0.08,
padding: paddingOverride,
onHover,
cursor = 'crosshair',
pulse = true,
mode = 'line',
candles,
candleWidth,
liveCandle,
lineMode,
lineData,
lineValue,
onModeChange,
onSeriesToggle,
seriesToggleCompact = false,
lineWidth,
className = '',
style,
surfaceStyle,
surfaceClassName = '',
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
...rootProps
},
forwardedRef,
) => {
const rootRef = useRef<HTMLDivElement>(null);
const surfaceRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const valueRef = useRef<HTMLSpanElement>(null);
const engineRef = useRef<LivelineEngine | null>(null);
const windowBarRef = useRef<HTMLDivElement>(null);
const windowButtonRefs = useRef(new Map<number, HTMLButtonElement>());
const modeBarRef = useRef<HTMLDivElement>(null);
const modeButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const [windowIndicator, setWindowIndicator] = useState<{ left: number; width: number } | null>(null);
const [modeIndicator, setModeIndicator] = useState<{ left: number; width: number } | null>(null);
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(() => new Set());
const [activeWindowSecs, setActiveWindowSecs] = useState(windows?.[0]?.secs ?? windowSecs);
const lastSeriesRef = useRef(seriesProp);
useLayoutEffect(() => {
if (seriesProp?.length) lastSeriesRef.current = seriesProp;
}, [seriesProp]);
const isMultiSeries = Boolean(seriesProp?.length);
const shownSeries = (seriesProp?.length ? seriesProp : lastSeriesRef.current) ?? [];
const showSeriesControls = shownSeries.length > 1;
const effectiveWindowSecs = windows?.length ? activeWindowSecs : windowSecs;
const activeMode = mode === 'candle' && lineMode ? 'line' : mode;
const isDark = theme === 'dark';
const activeColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.55)';
const inactiveColor = isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.22)';
const defaultRight = badge ? 80 : grid ? 54 : 12;
const padding = {
top: paddingOverride?.top ?? 12,
right: paddingOverride?.right ?? defaultRight,
bottom: paddingOverride?.bottom ?? 28,
left: paddingOverride?.left ?? 12,
};
const palette = { ...resolveTheme(color, theme), ...(lineWidth == null ? {} : { lineWidth }) };
const seriesPalettes = seriesProp?.length ? resolveSeriesPalettes(seriesProp, theme) : null;
const multiSeries = seriesProp?.map((series, index) => ({
id: series.id,
data: series.data,
value: series.value,
palette: seriesPalettes?.get(series.id) ?? resolveTheme(series.color || SERIES_COLORS[index % SERIES_COLORS.length], theme),
label: series.label,
}));
const config: LivelineEngineConfig = {
data,
value,
palette,
windowSecs: effectiveWindowSecs,
lerpSpeed,
showGrid: grid,
showBadge: isMultiSeries ? false : badge,
showMomentum: isMultiSeries ? false : momentum !== false,
momentumOverride: typeof momentum === 'string' ? (momentum as Momentum) : undefined,
showFill: isMultiSeries ? false : fill,
referenceLine,
formatValue,
formatTime,
padding,
onHover,
showPulse: pulse,
scrub,
exaggerate,
degenOptions: isMultiSeries || !degen ? undefined : typeof degen === 'object' ? degen : {},
badgeTail,
badgeVariant,
tooltipY,
tooltipOutline,
valueMomentumColor,
valueElement: showValue ? valueRef.current : undefined,
orderbookData: orderbook,
loading,
paused,
emptyText,
mode,
candles,
candleWidth,
liveCandle,
lineMode,
lineData,
lineValue,
multiSeries,
isMultiSeries,
hiddenSeriesIds: hiddenSeries,
};
const initialConfigRef = useRef(config);
const initialShowValueRef = useRef(showValue);
useLayoutEffect(() => {
setIndicator(windowBarRef.current, windowButtonRefs.current.get(activeWindowSecs), setWindowIndicator);
}, [activeWindowSecs, windows]);
useEffect(() => {
setActiveWindowSecs((current) => {
if (!windows?.length) return windowSecs;
return windows.some((option) => option.secs === current) ? current : windows[0].secs;
});
}, [windows, windowSecs]);
useEffect(() => {
if (!seriesProp?.length) {
setHiddenSeries((current) => (current.size ? new Set() : current));
return;
}
const ids = new Set(seriesProp.map((series) => series.id));
setHiddenSeries((current) => {
const next = new Set([...current].filter((id) => ids.has(id)));
if (next.size === ids.size) next.delete(seriesProp[0].id);
return next.size === current.size && [...next].every((id) => current.has(id)) ? current : next;
});
}, [seriesProp]);
useLayoutEffect(() => {
if (onModeChange) setIndicator(modeBarRef.current, modeButtonRefs.current.get(activeMode), setModeIndicator);
}, [activeMode, onModeChange]);
useEffect(() => {
const container = surfaceRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const engine = createLivelineEngine({ container, canvas }, { ...initialConfigRef.current, valueElement: initialShowValueRef.current ? valueRef.current : undefined });
engineRef.current = engine;
return () => {
engine.destroy();
if (engineRef.current === engine) engineRef.current = null;
};
}, []);
useEffect(() => {
engineRef.current?.update({ ...config, valueElement: showValue ? valueRef.current : undefined });
});
function setRootRef(element: HTMLDivElement | null) {
rootRef.current = element;
if (typeof forwardedRef === 'function') forwardedRef(element);
else if (forwardedRef) forwardedRef.current = element;
}
function toggleSeries(id: string) {
if (!isMultiSeries) return;
const next = new Set(hiddenSeries);
const visible = next.has(id);
if (visible) next.delete(id);
else {
if ((seriesProp?.length ?? 0) - next.size <= 1) return;
next.add(id);
}
setHiddenSeries(next);
onSeriesToggle?.(id, visible);
}
const summary = ariaLabel ?? `Live chart, current value ${formatValue(value)}`;
const hasControls = Boolean(windows?.length || onModeChange || showSeriesControls);
return (
<div {...rootProps} ref={setRootRef} data-slot="liveline" data-theme={theme} className={`${styles.root} ${className}`} style={style}>
{showValue && <span ref={valueRef} data-slot="liveline-value" className={styles.value} style={{ paddingLeft: padding.left }} aria-hidden="true" />}
{hasControls && (
<div data-slot="liveline-controls" className={styles.controls} style={{ paddingLeft: padding.left }}>
{Boolean(windows?.length) && (
<div
ref={windowBarRef}
role="group"
data-slot="liveline-window-controls"
data-style={windowStyle}
className={styles.controlGroup}
aria-label="Chart time range">
{windowStyle !== 'text' && windowIndicator && (
<span
data-slot="liveline-control-indicator"
className={styles.indicator}
style={{ left: windowIndicator.left, width: windowIndicator.width }}
/>
)}
{windows?.map((option) => {
const active = option.secs === activeWindowSecs;
return (
<button
key={option.secs}
ref={(element) => {
if (element) windowButtonRefs.current.set(option.secs, element);
else windowButtonRefs.current.delete(option.secs);
}}
type="button"
data-slot="liveline-window-control"
data-active={active || undefined}
className={styles.control}
aria-label={`Show ${option.label} time range`}
aria-pressed={active}
onClick={() => {
setActiveWindowSecs(option.secs);
onWindowChange?.(option.secs);
}}>
{option.label}
</button>
);
})}
</div>
)}
{onModeChange && (
<div ref={modeBarRef} role="group" data-slot="liveline-mode-controls" data-style={windowStyle} className={styles.controlGroup} aria-label="Chart type">
{windowStyle !== 'text' && modeIndicator && (
<span data-slot="liveline-control-indicator" className={styles.indicator} style={{ left: modeIndicator.left, width: modeIndicator.width }} />
)}
{(['line', 'candle'] as const).map((item) => {
const active = activeMode === item;
return (
<button
key={item}
ref={(element) => {
if (element) modeButtonRefs.current.set(item, element);
else modeButtonRefs.current.delete(item);
}}
type="button"
data-slot="liveline-mode-control"
data-active={active || undefined}
className={`${styles.control} ${styles.iconControl}`}
aria-label={`Show ${item} chart`}
aria-pressed={active}
onClick={() => onModeChange(item)}>
{item === 'line' ? (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M1 8.5C2.5 8.5 3 4 5.5 4S7.5 7 8.5 7C9.5 7 10 3.5 11 3.5"
stroke={active ? activeColor : inactiveColor}
strokeWidth={active ? 1.5 : 1.2}
strokeLinecap="round"
/>
</svg>
) : (
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" color={active ? activeColor : inactiveColor}>
<line x1="3.5" y1="1" x2="3.5" y2="11" stroke="currentColor" />
<rect x="2" y="3" width="3" height="5" rx="0.5" fill="currentColor" />
<line x1="8.5" y1="2" x2="8.5" y2="10" stroke="currentColor" />
<rect x="7" y="4" width="3" height="4" rx="0.5" fill="currentColor" />
</svg>
)}
</button>
);
})}
</div>
)}
{showSeriesControls && (
<div
role="group"
data-slot="liveline-series-controls"
data-style={windowStyle}
data-visible={isMultiSeries}
aria-hidden={!isMultiSeries}
className={styles.controlGroup}
aria-label="Chart series">
{shownSeries.map((series, index) => {
const visible = !hiddenSeries.has(series.id);
const label = series.label ?? series.id;
return (
<button
key={series.id}
type="button"
data-slot="liveline-series-control"
disabled={!isMultiSeries}
data-active={visible || undefined}
className={`${styles.control} ${seriesToggleCompact ? styles.compactControl : ''}`}
aria-label={`${visible ? 'Hide' : 'Show'} ${label} series`}
aria-pressed={visible}
onClick={() => toggleSeries(series.id)}>
<span
data-slot="liveline-series-swatch"
className={styles.swatch}
style={{ background: series.color || SERIES_COLORS[index % SERIES_COLORS.length] }}
/>
{!seriesToggleCompact && label}
</button>
);
})}
</div>
)}
</div>
)}
<div ref={surfaceRef} data-slot="liveline-surface" className={`${styles.surface} ${surfaceClassName}`} style={surfaceStyle}>
<canvas
ref={canvasRef}
data-slot="liveline-canvas"
className={styles.canvas}
style={{ cursor: scrub ? cursor : 'default' }}
role="img"
aria-label={summary}
aria-describedby={ariaDescribedBy}>
{summary}
</canvas>
</div>
</div>
);
},
);
Liveline.displayName = 'Liveline';
export { Liveline };
packages/react/src/components/liveline/liveline-transition.tsx
import React, { useEffect, useRef, useState } from 'react';
import styles from './liveline.module.scss';
export interface LivelineTransitionProps {
/** Key of the active child to display. Must match a child's `key` prop. */
active: string;
/** Chart elements with unique `key` props. */
children: React.ReactElement | React.ReactElement[];
/** Cross-fade duration in milliseconds. */
duration?: number;
className?: string;
style?: React.CSSProperties;
}
function prefersReducedMotion() {
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
const LivelineTransition = React.forwardRef<HTMLDivElement, LivelineTransitionProps>(({ active, children, duration = 300, className = '', style }, ref) => {
const childArray = Array.isArray(children) ? children : [children];
const [mounted, setMounted] = useState<Set<string>>(() => new Set([active]));
const [visible, setVisible] = useState(active);
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
return;
}
if (prefersReducedMotion()) {
setMounted(new Set([active]));
setVisible(active);
return;
}
setMounted((current) => new Set([...current, active]));
let secondFrame = 0;
const firstFrame = requestAnimationFrame(() => {
secondFrame = requestAnimationFrame(() => setVisible(active));
});
const timer = window.setTimeout(() => {
setMounted(new Set([active]));
setVisible(active);
}, duration + 50);
return () => {
cancelAnimationFrame(firstFrame);
cancelAnimationFrame(secondFrame);
clearTimeout(timer);
};
}, [active, duration]);
return (
<div ref={ref} data-slot="liveline-transition" className={`${styles.transition} ${className}`} style={style}>
{childArray.map((child) => {
const key = String(child.key ?? '');
if (!mounted.has(key)) return null;
const isActive = key === visible;
return (
<div
key={key}
data-slot="liveline-transition-layer"
data-active={isActive || undefined}
aria-hidden={!isActive}
// A nonempty attribute works in both React 18 (unknown) and 19 (boolean).
{...(!isActive ? { inert: 'inert' as unknown as boolean } : {})}
className={styles.transitionLayer}
style={{ transitionDuration: `${duration}ms` }}>
{child}
</div>
);
})}
</div>
);
});
LivelineTransition.displayName = 'LivelineTransition';
export { LivelineTransition };
packages/styles/scss/components/liveline.module.scss
.root {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.value {
grid-row: 1;
display: block;
padding-top: 4px;
margin-bottom: 8px;
font-family: 'SF Mono', Menlo, monospace;
font-size: 20px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
color: #111;
transition: color 0.3s;
}
.root[data-theme='dark'] .value {
color: rgba(255, 255, 255, 0.85);
}
.controls {
grid-row: 2;
display: flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 6px;
padding-right: 4px;
margin-bottom: 6px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-inline: contain;
scrollbar-width: thin;
}
.controlGroup {
position: relative;
display: inline-flex;
flex: 0 0 auto;
gap: 2px;
padding: 2px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.02);
transition: opacity 0.4s;
}
.root[data-theme='dark'] .controlGroup {
background: rgba(255, 255, 255, 0.03);
}
.controlGroup[data-style='rounded'] {
padding: 3px;
border-radius: 999px;
}
.root .controlGroup[data-style='text'] {
gap: 4px;
padding: 0;
background: transparent;
}
.controlGroup[data-visible='false'] {
pointer-events: none;
opacity: 0;
}
.indicator {
pointer-events: none;
position: absolute;
top: 2px;
height: calc(100% - 4px);
border-radius: 4px;
background: rgba(0, 0, 0, 0.035);
transition:
left 0.25s cubic-bezier(0.4, 0, 0.2, 1),
width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.root[data-theme='dark'] .indicator {
background: rgba(255, 255, 255, 0.06);
}
.controlGroup[data-style='rounded'] .indicator {
top: 3px;
height: calc(100% - 6px);
border-radius: 999px;
}
.control {
position: relative;
z-index: 1;
display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: rgba(0, 0, 0, 0.22);
font:
400 11px/16px system-ui,
-apple-system,
sans-serif;
white-space: nowrap;
cursor: pointer;
transition:
opacity 0.2s,
color 0.2s,
background 0.15s;
}
.controlGroup[data-style='rounded'] .control {
border-radius: 999px;
}
.controlGroup[data-style='text'] .control {
padding: 2px 6px;
}
.root[data-theme='dark'] .control {
color: rgba(255, 255, 255, 0.25);
}
.control[data-active] {
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
}
.root[data-theme='dark'] .control[data-active] {
color: rgba(255, 255, 255, 0.7);
}
.control:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
.controlGroup[data-style] .iconControl {
padding: 5px 7px;
}
.compactControl {
padding: 5px 7px;
gap: 0;
}
.controlGroup[data-style='text'] .compactControl {
padding: 2px 4px;
}
.controlGroup:not([data-style='text']) [data-slot='liveline-window-control'] {
padding: 3px 10px;
}
.root [data-slot='liveline-series-control'] {
font-weight: 500;
}
.swatch {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
transition: opacity 0.2s;
}
.root [data-slot='liveline-series-control']:not([data-active]),
.root [data-slot='liveline-series-control']:not([data-active]) .swatch {
opacity: 0.4;
}
.root [data-slot='liveline-series-control'][data-active] {
background: rgba(0, 0, 0, 0.035);
}
.root[data-theme='dark'] [data-slot='liveline-series-control'][data-active] {
background: rgba(255, 255, 255, 0.06);
}
.root .controlGroup[data-style='text'] [data-slot='liveline-series-control'][data-active] {
background: transparent;
}
.compactControl .swatch {
width: 8px;
height: 8px;
}
.surface {
position: relative;
grid-row: 3;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
touch-action: pan-y;
}
.canvas {
display: block;
}
.transition {
position: relative;
width: 100%;
height: 100%;
}
.transitionLayer {
pointer-events: none;
position: absolute;
inset: 0;
opacity: 0;
transition-property: opacity;
transition-timing-function: ease;
}
.transitionLayer[data-active] {
pointer-events: auto;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.value,
.indicator,
.control,
.controlGroup,
.transitionLayer {
transition-duration: 0.01ms !important;
}
}
packages/react/src/components/liveline/index.ts
export { Liveline, type LivelineProps } from './liveline';
export { LivelineTransition, type LivelineTransitionProps } from './liveline-transition';
export type {
BadgeVariant,
CandlePoint,
DegenOptions,
HoverPoint,
LivelineOptions,
LivelinePoint,
LivelineSeries,
Momentum,
OrderbookData,
Padding,
ReferenceLine,
ThemeMode,
WindowOption,
WindowStyle,
} from '../lib/liveline';