shadimage
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 · light

Registry 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';

More from Awesome Cursor Rules

Browse all components in this library →