add liveline page: recreation of benji.org/liveline editorial design

Recreates the benji.org/liveline page in the website folder using React,
Tailwind, and the same editorial design system. Charts are replaced with
static SVG placeholders.

Typography:
- Inter variable font from rsms.me (same source as next/font)
- font-weight 475 body, 560 headings, font-optical-sizing: auto
- 14px body, 20px line-height, -0.09px letter-spacing
- Near-black text rgb(17,17,17), 40% opacity secondary
- Display P3 accent colors with @supports fallback

Layout:
- 550px narrow content column, fixed TOC sidebar
- Stagger-in entrance animation with cascading delays
- Fixed header fade gradient (multi-stop white-to-transparent)
- Scroll-spy TOC highlighting via IntersectionObserver

Code blocks:
- Prism.js syntax highlighting with GitHub-light color palette
- Line numbers column with 20px right padding
- 12px / 18px font size

Props tables, dividers, captions, code blocks, bullet lists all
styled to match the original computed values.
This commit is contained in:
Tommy D. Rossi
2026-02-19 09:56:30 +01:00
parent 38098c6153
commit a8481af89b
6 changed files with 1308 additions and 2 deletions
+19 -2
View File
@@ -427,6 +427,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.18)
'@types/prismjs':
specifier: ^1.26.6
version: 1.26.6
'@vercel/react-router':
specifier: ^1.2.4
version: 1.2.4(@react-router/dev@7.11.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(tsx@4.20.6)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))(yaml@2.6.0))(@react-router/node@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(isbot@5.1.32)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -442,6 +445,9 @@ importers:
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
prismjs:
specifier: ^1.30.0
version: 1.30.0
react:
specifier: ^19.2.3
version: 19.2.3
@@ -499,7 +505,7 @@ importers:
version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))
vitest:
specifier: ^4.0.16
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8(vitest@4.0.8))(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0)
packages:
@@ -2773,6 +2779,9 @@ packages:
'@types/pg@8.6.1':
resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==}
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -5467,6 +5476,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
@@ -9257,6 +9270,8 @@ snapshots:
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/prismjs@1.26.6': {}
'@types/react-dom@19.2.3(@types/react@19.2.7)':
dependencies:
'@types/react': 19.2.7
@@ -12368,6 +12383,8 @@ snapshots:
prettier@3.7.4: {}
prismjs@1.30.0: {}
process-warning@5.0.0: {}
progress@2.0.3: {}
@@ -13516,7 +13533,7 @@ snapshots:
tsx: 4.20.6
yaml: 2.6.0
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8(vitest@4.0.8))(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.6.0))
+2
View File
@@ -16,11 +16,13 @@
"@react-router/node": "^7.11.0",
"@sentry/node": "^10.32.1",
"@tailwindcss/typography": "^0.5.19",
"@types/prismjs": "^1.26.6",
"@vercel/react-router": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"prismjs": "^1.30.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "^7.11.0",
+15
View File
@@ -15,6 +15,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
{/* Inter from rsms (same source as next/font) for weight fidelity */}
<link
href="https://rsms.me/inter/inter.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&display=swap"
rel="stylesheet"
/>
<Meta />
<Links />
</head>
+990
View File
@@ -0,0 +1,990 @@
/*
* Recreation of benji.org/liveline page design.
* Uses the same editorial layout, typography, and styling system.
* Chart canvas components are replaced with dark placeholder boxes.
*
* Prism.js is used for syntax highlighting with a custom light theme
* that matches the original benji.org subtle code block style.
*/
import { useEffect, useRef, useState } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-bash";
import "website/src/styles/liveline.css";
import "website/src/styles/liveline-prism.css";
/* =========================================================================
TOC sidebar (fixed left)
========================================================================= */
/*
* Tracks which section heading is currently in the top ~25% of the viewport.
* Uses IntersectionObserver with rootMargin to create a detection zone:
* -80px top (fixed header offset), -75% bottom (only top quarter triggers).
* When multiple headings intersect, the last one in DOM order wins since
* that's the section the user is actually reading.
*/
function useActiveTocId() {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const headings = document.querySelectorAll<HTMLElement>("h1[id]");
if (headings.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
// Collect all currently-intersecting heading ids
const visible: string[] = [];
entries.forEach((entry) => {
if (entry.isIntersecting && entry.target.id) {
visible.push(entry.target.id);
}
});
if (visible.length > 0) {
// Pick the last one in DOM order (furthest down the page)
const sorted = visible.sort((a, b) => {
const elA = document.getElementById(a);
const elB = document.getElementById(b);
if (!elA || !elB) {
return 0;
}
return elA.getBoundingClientRect().top - elB.getBoundingClientRect().top;
});
setActiveId(sorted[sorted.length - 1]);
}
},
{
// 80px for fixed header, bottom -75% so only top quarter triggers
rootMargin: "-80px 0px -75% 0px",
threshold: 0,
},
);
headings.forEach((heading) => {
observer.observe(heading);
});
return () => {
observer.disconnect();
};
}, []);
return activeId;
}
const tocItems = [
{ label: "Getting started", href: "#getting-started" },
{ label: "Momentum", href: "#momentum" },
{ label: "Value overlay", href: "#value-overlay" },
{ label: "Time windows", href: "#time-windows" },
{ label: "Reference line", href: "#reference-line" },
{ label: "Orderbook", href: "#orderbook" },
{ label: "Theming", href: "#theming" },
{ label: "More features", href: "#more-features" },
{ label: "How it works", href: "#how-it-works" },
{ label: "Props", href: "#props" },
{ label: "Stress testing", href: "#stress-testing" },
{ label: "Just a line", href: "#just-a-line" },
{ label: "Acknowledgements", href: "#acknowledgements" },
];
function TableOfContents() {
const activeId = useActiveTocId();
return (
<aside
className="fixed top-[80px] hidden lg:block"
style={{ left: "max(1rem, calc((100vw - 550px) / 2 - 200px))", width: "122px" }}
>
<nav>
<a
href="/"
className="no-underline transition-colors flex items-center gap-1"
style={{
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
padding: "4px 0",
color: "rgba(0, 0, 0, 0.4)",
fontFamily: "var(--ll-font-primary)",
marginBottom: "8px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "rgba(0, 0, 0, 0.4)";
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{" "}Index
</a>
{tocItems.map((item) => {
const isActive = `#${activeId}` === item.href;
const defaultColor = isActive ? "rgba(0, 0, 0, 0.9)" : "rgba(0, 0, 0, 0.4)";
return (
<a
key={item.href}
href={item.href}
className="block no-underline"
style={{
fontSize: "13px",
fontWeight: 475,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
padding: "3px 0",
color: defaultColor,
fontFamily: "var(--ll-font-primary)",
transition: "color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = defaultColor;
}}
>
{item.label}
</a>
);
})}
</nav>
</aside>
);
}
/* =========================================================================
Back button (fixed top-right)
========================================================================= */
function BackButton() {
return (
<a
href="/"
className="fixed top-5 right-5 z-[100000] flex items-center justify-center w-10 h-10 rounded-full bg-white no-underline"
style={{
color: "rgba(0, 0, 0, 0.4)",
boxShadow:
"rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.04) 0px 4px 16px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px inset",
transition: "color 0.15s, transform 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "rgba(0, 0, 0, 0.7)";
e.currentTarget.style.transform = "scale(1.05)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "rgba(0, 0, 0, 0.4)";
e.currentTarget.style.transform = "scale(1)";
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = "scale(0.95)";
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = "scale(1.05)";
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M12.25 7H1.75M1.75 7L6.125 2.625M1.75 7L6.125 11.375"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
);
}
/* =========================================================================
Reusable components
========================================================================= */
function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
return (
<h1
id={id}
className="scroll-mt-[5.25rem]"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 560,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
padding: 0,
transform: "translateY(-10px)",
}}
>
{children}
</h1>
);
}
function Paragraph({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<p
className={className}
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
margin: 0,
}}
>
{children}
</p>
);
}
function Caption({ children }: { children: React.ReactNode }) {
return (
<p
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "12px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "rgba(0, 0, 0, 0.4)",
margin: 0,
}}
>
{children}
</p>
);
}
function Divider() {
return (
<div
style={{
height: "1px",
background: "rgb(242, 242, 242)",
margin: 0,
}}
/>
);
}
function CodeBlock({ children, lang = "jsx" }: { children: string; lang?: string }) {
const codeRef = useRef<HTMLElement>(null);
const lines = children.split("\n");
useEffect(() => {
if (codeRef.current) {
Prism.highlightElement(codeRef.current);
}
}, [children]);
return (
<figure className="m-0">
<div className="relative">
<pre
className="overflow-x-auto"
style={{
background: "var(--ll-code-bg)",
borderRadius: "8px",
margin: 0,
padding: 0,
}}
>
<div
className="flex"
style={{
padding: "12px 8px 8px",
fontFamily: "var(--ll-font-code)",
fontSize: "12px",
fontWeight: 400,
lineHeight: "18px",
letterSpacing: "normal",
color: "var(--ll-text-primary)",
tabSize: 2,
}}
>
{/* Line numbers */}
<span
className="select-none shrink-0"
aria-hidden="true"
style={{
color: "rgba(0, 0, 0, 0.15)",
textAlign: "right",
paddingRight: "20px",
minWidth: "28px",
userSelect: "none",
}}
>
{lines.map((_, i) => {
return (
<span key={i} className="block">
{i + 1}
</span>
);
})}
</span>
{/* Highlighted code */}
<code
ref={codeRef}
className={`language-${lang}`}
style={{ whiteSpace: "pre", background: "none", padding: 0 }}
>
{children}
</code>
</div>
</pre>
</div>
</figure>
);
}
function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code
style={{
fontFamily: "var(--ll-font-code)",
fontSize: "12.25px",
fontWeight: 475,
lineHeight: "12.25px",
letterSpacing: "-0.09px",
padding: "0 4px",
}}
>
{children}
</code>
);
}
function ChartPlaceholder({ height = 200, label }: { height?: number; label?: string }) {
return (
<div className="my-4">
<div
className="w-full rounded-lg overflow-hidden relative"
style={{
height: `${height}px`,
background: "rgb(17, 17, 17)",
}}
>
{/* Simulated chart line */}
<svg
viewBox="0 0 550 200"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60"
fill="none"
stroke="#3b82f6"
strokeWidth="2"
/>
<path
d="M0,140 C30,135 60,120 90,125 C120,130 150,100 180,95 C210,90 240,110 270,105 C300,100 330,80 360,85 C390,90 420,70 450,65 C480,60 510,75 550,60 L550,200 L0,200 Z"
fill="url(#chartFill)"
/>
{/* Live dot */}
<circle cx="550" cy="60" r="4" fill="#3b82f6">
<animate
attributeName="r"
values="4;6;4"
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="1;0.6;1"
dur="2s"
repeatCount="indefinite"
/>
</circle>
</svg>
{label && (
<div
className="absolute top-3 right-3 px-2 py-1 rounded text-xs"
style={{
background: "rgba(59, 130, 246, 0.15)",
color: "#3b82f6",
fontFamily: "var(--ll-font-code)",
fontWeight: 475,
fontSize: "11px",
}}
>
{label}
</div>
)}
</div>
</div>
);
}
function PropsTable({
title,
rows,
}: {
title?: string;
rows: Array<[string, string, string]>;
}) {
return (
<div className="w-full max-w-full overflow-x-auto" style={{ padding: "8px 0" }}>
{title && (
<div
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "11px",
fontWeight: 400,
color: "rgba(0, 0, 0, 0.35)",
textTransform: "uppercase",
letterSpacing: "0.02em",
padding: "0 0 6px",
}}
>
{title}
</div>
)}
<table
className="w-full"
style={{
borderSpacing: 0,
borderCollapse: "collapse",
}}
>
<thead>
<tr>
{["Prop", "Type", "Default"].map((header) => {
return (
<th
key={header}
className="text-left"
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-primary)",
color: "rgba(0, 0, 0, 0.35)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{header}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map(([prop, type, def]) => {
return (
<tr key={prop}>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "var(--ll-text-primary)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{prop}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "rgba(0, 0, 0, 0.35)",
borderBottom: "1px solid var(--ll-border)",
whiteSpace: "nowrap",
}}
>
{type}
</td>
<td
style={{
padding: "4.8px 12px 4.8px 0",
fontSize: "11px",
fontWeight: 400,
fontFamily: "var(--ll-font-code)",
color: "rgba(0, 0, 0, 0.35)",
borderBottom: "1px solid var(--ll-border)",
}}
>
{def}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/* =========================================================================
Main page
========================================================================= */
export default function LivelinePage() {
return (
<div
className="liveline-page relative min-h-screen"
style={{
background: "var(--ll-bg)",
color: "var(--ll-text-primary)",
fontFamily: "var(--ll-font-primary)",
WebkitFontSmoothing: "antialiased",
textRendering: "optimizeLegibility",
}}
>
<BackButton />
<TableOfContents />
<div
className="mx-auto"
style={{ width: "550px", maxWidth: "calc(100% - 2rem)", padding: "0 1rem 6rem" }}
>
{/* Page header */}
<header style={{ padding: "80px 0 40px" }}>
<h2
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "13px",
fontWeight: 475,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
color: "rgba(18, 18, 18, 0.4)",
margin: 0,
padding: "0 0 16px",
}}
>
Liveline
</h2>
<time
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "13px",
fontWeight: 400,
lineHeight: "15.6px",
letterSpacing: "-0.04px",
color: "rgba(18, 18, 18, 0.25)",
}}
>
16 February, 2026
</time>
</header>
<article className="liveline-article flex flex-col gap-[16px]">
{/* Intro */}
<Paragraph>
Liveline is a real-time animated line chart component for React. One{" "}
<InlineCode>{"<canvas>"}</InlineCode>, no dependencies beyond React
18, smooth interpolation at 60fps.
</Paragraph>
<ChartPlaceholder height={300} label="684.89" />
<Caption>
Degen mode (chart shake and particles) with momentum arrows.
</Caption>
<Paragraph>
I built this because every charting library I tried was either too
heavy for a simple live feed, or too rigid to feel alive. Liveline
does one thing: draw a line that moves smoothly as new data arrives.
Everything else is opt-in.
</Paragraph>
<Divider />
{/* Getting started */}
<SectionHeading id="getting-started">Getting started</SectionHeading>
<CodeBlock lang="bash">npm install liveline</CodeBlock>
<Paragraph>
The component fills its parent container. Set a height on the
wrapper.
</Paragraph>
<CodeBlock>
{`import { Liveline } from 'liveline'
function Chart({ data, value }) {
return (
<div style={{ height: 200 }}>
<Liveline data={data} value={value} />
</div>
)
}`}
</CodeBlock>
<Paragraph>
<InlineCode>data</InlineCode> is an array of{" "}
<InlineCode>{"{ time, value }"}</InlineCode> points.{" "}
<InlineCode>value</InlineCode> is the latest number.
</Paragraph>
<ChartPlaceholder height={200} label="303.34" />
<Caption>Two props. That&apos;s it.</Caption>
<Paragraph>
Feed it data however you like. WebSocket, polling, random walk.
Liveline interpolates between updates so even infrequent data looks
smooth. It works for anything with a value that changes over time.
</Paragraph>
<ChartPlaceholder height={200} label="98 bpm" />
<Caption>
Resting heart rate. Custom formatter, exaggerated Y-axis.
</Caption>
<Divider />
{/* Momentum */}
<SectionHeading id="momentum">Momentum</SectionHeading>
<Paragraph>
The <InlineCode>momentum</InlineCode> prop adds directional arrows
and a glow to the live dot. Green for up, red for down, grey for
flat. Pass <InlineCode>true</InlineCode> to auto-detect direction,
or force it with{" "}
<InlineCode>&quot;up&quot;</InlineCode>,{" "}
<InlineCode>&quot;down&quot;</InlineCode>, or{" "}
<InlineCode>&quot;flat&quot;</InlineCode>.
</Paragraph>
<ChartPlaceholder height={220} label="449.73" />
<Caption>
Arrows fade out fully before the new direction fades in.
</Caption>
<Divider />
{/* Value overlay */}
<SectionHeading id="value-overlay">Value overlay</SectionHeading>
<Paragraph>
<InlineCode>showValue</InlineCode> renders the current value as a
large number over the chart. It updates at 60fps through direct DOM
manipulation, not React re-renders. Pair it with{" "}
<InlineCode>valueMomentumColor</InlineCode> to tint the number based
on direction.
</Paragraph>
<ChartPlaceholder height={204} label="$17,138.03" />
<Caption>60fps value overlay with momentum colouring.</Caption>
<Divider />
{/* Time windows */}
<SectionHeading id="time-windows">Time windows</SectionHeading>
<Paragraph>
Pass a <InlineCode>windows</InlineCode> array to render time horizon
buttons. Each entry has a <InlineCode>label</InlineCode> and{" "}
<InlineCode>secs</InlineCode> value. Three styles are available via{" "}
<InlineCode>windowStyle</InlineCode>:{" "}
<InlineCode>&quot;default&quot;</InlineCode>,{" "}
<InlineCode>&quot;rounded&quot;</InlineCode>, and{" "}
<InlineCode>&quot;text&quot;</InlineCode>.
</Paragraph>
<CodeBlock>
{`<Liveline
windows={[
{ label: '1m', secs: 60 },
{ label: '5m', secs: 300 },
]}
windowStyle="rounded"
/>`}
</CodeBlock>
<ChartPlaceholder height={186} label="30%" />
<Caption>
CPU usage with occasional spikes. Rounded time windows.
</Caption>
<Divider />
{/* Reference line */}
<SectionHeading id="reference-line">Reference line</SectionHeading>
<Paragraph>
<InlineCode>referenceLine</InlineCode> draws a horizontal line at a
fixed value. Pass an object with <InlineCode>value</InlineCode> and
an optional <InlineCode>label</InlineCode>.
</Paragraph>
<ChartPlaceholder height={186} label="$-18,576" />
<Caption>
Polymarket-style prediction line. &quot;Will Bitcoin stay above
$67,500?&quot;
</Caption>
<Divider />
{/* Orderbook */}
<SectionHeading id="orderbook">Orderbook</SectionHeading>
<Paragraph>
Pass an <InlineCode>orderbook</InlineCode> prop with{" "}
<InlineCode>bids</InlineCode> and <InlineCode>asks</InlineCode>{" "}
arrays to render streaming order labels behind the line. Each entry
is a <InlineCode>[price, size]</InlineCode> tuple. Labels spawn at
the bottom, drift upward, and fade out. Green for bids, red for
asks. Bigger orders appear brighter.
</Paragraph>
<Paragraph>
The stream speed reacts to price momentum and orderbook churn (how
much the bid/ask totals are changing). Calm markets drift slowly,
volatile ones rush.
</Paragraph>
<ChartPlaceholder height={260} label="$61,904" />
<Caption>
Kalshi-style orderbook stream. Bid and ask sizes float upward behind
the price line.
</Caption>
<Divider />
{/* Theming */}
<SectionHeading id="theming">Theming</SectionHeading>
<Paragraph>
Pass any CSS colour string to <InlineCode>color</InlineCode> and
Liveline derives the full palette. Line, fill gradient, glow, badge,
grid labels. It converts the input to HSL and generates every variant
from there.
</Paragraph>
<ChartPlaceholder height={204} label="540.42" />
<Caption>Dark theme. Same component, different colour.</Caption>
<Divider />
{/* More features */}
<SectionHeading id="more-features">More features</SectionHeading>
<Paragraph>
Everything is off by default or has sensible defaults. A few more
things you can turn on:
</Paragraph>
<ul
className="m-0 pl-5"
style={{
fontFamily: "var(--ll-font-primary)",
fontSize: "14px",
fontWeight: 475,
lineHeight: "20px",
letterSpacing: "-0.09px",
color: "var(--ll-text-primary)",
listStyleType: "disc",
}}
>
<li style={{ padding: "0 0 8px 12px" }}>
<InlineCode>exaggerate</InlineCode> tightens the Y-axis range so
small movements fill the full chart height. Useful for values that
move in tiny increments, like the heart rate demo above.
</li>
<li style={{ padding: "0 0 8px 12px" }}>
<InlineCode>scrub</InlineCode> shows a crosshair with time and
value tooltips on hover. On by default.
</li>
<li style={{ padding: "0 0 8px 12px" }}>
<InlineCode>degen</InlineCode> enables burst particles and chart
shake on momentum swings. For when subtlety is not the goal.
</li>
<li style={{ padding: "0 0 8px 12px" }}>
<InlineCode>badgeVariant=&quot;minimal&quot;</InlineCode> renders a
quieter white pill instead of the accent-colored default. Or{" "}
<InlineCode>{"badge={false}"}</InlineCode> to remove it entirely.
</li>
</ul>
<Divider />
{/* How it works */}
<SectionHeading id="how-it-works">How it works</SectionHeading>
<Paragraph>
One <InlineCode>{"<canvas>"}</InlineCode>, one{" "}
<InlineCode>requestAnimationFrame</InlineCode> loop. When a new
value arrives, nothing jumps. The chart lerps toward the new state at
8% per frame (<InlineCode>lerpSpeed</InlineCode>). The Y-axis range,
the badge, the grid labels all use the same lerp. The range snaps
outward instantly when data exceeds it, so the line is never clipped.
That&apos;s why it feels like one thing breathing rather than a bunch
of parts updating independently.
</Paragraph>
<Divider />
{/* Props */}
<SectionHeading id="props">Props</SectionHeading>
<PropsTable
title="Required"
rows={[
["data", "LivelinePoint[]", "required"],
["value", "number", "required"],
]}
/>
<PropsTable
title="Appearance"
rows={[
["theme", "'light' | 'dark'", "'dark'"],
["color", "string", "'#3b82f6'"],
["grid", "boolean", "true"],
["badge", "boolean", "true"],
["badgeVariant", "'default' | 'minimal'", "'default'"],
["badgeTail", "boolean", "true"],
["fill", "boolean", "true"],
["pulse", "boolean", "true"],
]}
/>
<PropsTable
title="Behaviour"
rows={[
["momentum", "boolean | Momentum", "true"],
["scrub", "boolean", "true"],
["exaggerate", "boolean", "false"],
["showValue", "boolean", "false"],
["valueMomentumColor", "boolean", "false"],
["degen", "boolean | DegenOptions", "false"],
]}
/>
<PropsTable
title="Time"
rows={[
["window", "number", "30"],
["windows", "WindowOption[]", "\u2014"],
["onWindowChange", "(secs: number) => void", "\u2014"],
["windowStyle", "'default' | 'rounded' | 'text'", "\u2014"],
]}
/>
<PropsTable
title="Tooltip"
rows={[
["tooltipY", "number", "14"],
["tooltipOutline", "boolean", "true"],
]}
/>
<PropsTable
title="Orderbook"
rows={[["orderbook", "OrderbookData", "\u2014"]]}
/>
<PropsTable
title="Advanced"
rows={[
["referenceLine", "ReferenceLine", "\u2014"],
["formatValue", "(v: number) => string", "v.toFixed(2)"],
["formatTime", "(t: number) => string", "HH:MM:SS"],
["lerpSpeed", "number", "0.08"],
[
"padding",
"Padding",
"{ top: 12, right: 80, bottom: 28, left: 12 }",
],
["onHover", "(point: HoverPoint | null) => void", "\u2014"],
["cursor", "string", "'crosshair'"],
["className", "string", "\u2014"],
["style", "CSSProperties", "\u2014"],
]}
/>
<Divider />
{/* Stress testing */}
<SectionHeading id="stress-testing">Stress testing</SectionHeading>
<Paragraph>
A chart that only looks good on calm data isn&apos;t much use. These
demos throw the worst stuff I could think of at it: wild volatility,
sharp direction changes, isolated spikes on flat lines, and irregular
data arrival with random gaps.
</Paragraph>
<ChartPlaceholder height={200} />
<Paragraph>
Sharp reversals are the classic breaking point. The first chart
hammers the line with frequent direction changes at 60ms. The second
holds nearly flat, then fires massive isolated spikes. The third is
just chaos.
</Paragraph>
<ChartPlaceholder height={200} />
<Paragraph>
Real-world data doesn&apos;t arrive at regular intervals. WebSocket
connections drop, batch updates land all at once, mobile networks
stall. This one simulates that: long quiet stretches of 1&ndash;3
seconds between points, then sudden bursts at 40&ndash;80ms. The
tick interval itself is random.
</Paragraph>
<ChartPlaceholder height={200} />
<Divider />
{/* Just a line */}
<SectionHeading id="just-a-line">Just a line</SectionHeading>
<Paragraph>
Liveline can do a lot. Momentum arrows, particles, orderbooks,
scrubbing, time windows. But at the end of the day, if you just want
a line that moves when a number changes, it does that just fine too.
</Paragraph>
<ChartPlaceholder height={180} />
<Divider />
{/* Acknowledgements */}
<SectionHeading id="acknowledgements">
Acknowledgements
</SectionHeading>
<Paragraph>
Built with React and HTML Canvas. Inspired by TradingView, Robinhood,
and Polymarket chart aesthetics. The interpolation approach is
borrowed from game development &mdash; lerp everything, snap nothing.
</Paragraph>
</article>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
/*
* Prism.js light theme matching benji.org's GitHub-style syntax highlighting.
*
* Color palette extracted from the original:
* - Keywords (import, function, return): #CF222E (red)
* - Strings: #0A3069 (dark blue)
* - Functions/components: #8250DF (purple)
* - Parameters/properties: #953800 (orange-brown)
* - HTML tags: #116329 (green)
* - Attributes/numbers: #0550AE (blue)
* - Punctuation/text: #1F2328 (near-black)
*/
/* Base code style - override Prism defaults */
code[class*="language-"],
pre[class*="language-"] {
color: #1f2328;
background: none;
text-shadow: none;
font-family: "SF Mono", "SFMono-Regular", "Consolas", "Liberation Mono",
Menlo, Courier, monospace;
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
tab-size: 2;
hyphens: none;
}
/* Keywords: import, from, function, return, const, let, var, export, default */
.token.keyword,
.token.module,
.token.imports,
.token.control-flow {
color: #cf222e;
}
/* Strings and template literals */
.token.string,
.token.template-string,
.token.attr-value {
color: #0a3069;
}
/* Function names */
.token.function,
.token.class-name {
color: #8250df;
}
/* Object properties, parameters, variables in destructuring */
.token.property,
.token.parameter {
color: #953800;
}
/* HTML/JSX tag names */
.token.tag .token.tag {
color: #116329;
}
/* Also color component names (PascalCase tags) in JSX */
.token.tag .token.class-name {
color: #116329;
}
/* Attributes in JSX/HTML */
.token.tag .token.attr-name,
.token.attr-name {
color: #0550ae;
}
/* Numbers */
.token.number {
color: #0550ae;
}
/* Operators: =, ===, =>, +, - */
.token.operator {
color: #cf222e;
}
/* Punctuation: brackets, parens, commas, semicolons */
.token.punctuation {
color: #1f2328;
}
/* Booleans, null, undefined */
.token.boolean,
.token.constant,
.token.builtin {
color: #0550ae;
}
/* Comments */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #6e7781;
font-style: italic;
}
/* JSX specific: tag punctuation < > / */
.token.tag .token.punctuation {
color: #1f2328;
}
/* Spread operator, rest */
.token.spread {
color: #cf222e;
}
/* Plain text inside tags */
.token.plain-text {
color: #1f2328;
}
/* Regex */
.token.regex {
color: #0a3069;
}
/* Shell/bash specific */
.language-bash .token.function {
color: #0550ae;
}
/* No text shadow on any token */
.token {
text-shadow: none;
}
+147
View File
@@ -0,0 +1,147 @@
/*
* Liveline page styles - recreating benji.org's editorial design system.
*
* Key design decisions from the original:
* - Inter variable font at weight 460 (between normal and medium)
* - Near-black text (#111) not pure black, with 40% opacity for secondary
* - Narrow 550px content column for optimal reading
* - Negative letter-spacing on headings for tight, refined feel
* - 7-layer box shadows for realistic depth
* - Display P3 accent colors (wider gamut than sRGB)
* - Staggered entrance animations with subtle translateY
* - Custom cubic-bezier easings with slight overshoot ("snappy", "swift")
* - Fading header gradient pseudo-element
* - Small font sizes (14px body) for editorial/compact aesthetic
*/
/* ========================================================================
Design tokens
======================================================================== */
.liveline-page {
--ll-font-primary: "Inter var", "Inter", system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ll-font-secondary: "Newsreader", Georgia, "Times New Roman", serif;
--ll-font-code: "SF Mono", "SFMono-Regular", "Consolas", "Liberation Mono",
Menlo, Courier, monospace;
/* Spacing scale */
--ll-spacing-xxs: 0.5rem;
--ll-spacing-xs: 1rem;
--ll-spacing-sm: 1.5rem;
--ll-spacing-md: 2rem;
--ll-spacing-lg: 2.5rem;
--ll-spacing-xl: 3rem;
--ll-spacing-xxl: 3.5rem;
/* Timing */
--ll-duration-snappy: 220ms;
--ll-ease-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.1);
--ll-duration-swift: 800ms;
--ll-ease-swift: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--ll-duration-smooth: 300ms;
--ll-ease-smooth: cubic-bezier(0.19, 1, 0.22, 1);
/* Colors - near-black, not pure black */
--ll-text-primary: rgb(17, 17, 17);
--ll-text-secondary: rgba(0, 0, 0, 0.4);
--ll-text-tertiary: rgba(0, 0, 0, 0.25);
--ll-bg: #fff;
--ll-border: #e3e3e3;
--ll-code-bg: rgba(0, 0, 0, 0.02);
--ll-selection-bg: rgba(0, 0, 0, 0.08);
/* P3 accent colors (wider gamut) */
--ll-primary: #3e9fff;
--ll-secondary: #f09637;
/* Overlay / glass */
--ll-overlay-filter: blur(1rem);
--ll-overlay-bg: hsla(0, 0%, 100%, 0.8);
--ll-overlay-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04),
0 1.625rem 3.375rem rgba(0, 0, 0, 0.04),
0 1rem 2rem rgba(0, 0, 0, 0.03),
0 0.625rem 1rem rgba(0, 0, 0, 0.024),
0 0.3125rem 0.5rem rgba(0, 0, 0, 0.02),
0 0.125rem 0.25rem rgba(0, 0, 0, 0.016),
0 0 0.125rem rgba(0, 0, 0, 0.01);
}
@supports (color: color(display-p3 1 1 1)) {
.liveline-page {
--ll-primary: color(display-p3 0.243137 0.623529 1 / 1);
--ll-secondary: color(display-p3 0.941176 0.588235 0.215686 / 1);
}
}
/* ========================================================================
Selection
======================================================================== */
.liveline-page {
font-optical-sizing: auto;
font-feature-settings: "liga" 1, "calt" 1;
}
.liveline-page ::selection {
background: var(--ll-selection-bg);
}
/* ========================================================================
Stagger-in animation
======================================================================== */
@keyframes ll-stagger-in {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.liveline-article > * {
animation: ll-stagger-in 0.5s ease both;
}
.liveline-article > :nth-child(1) { animation-delay: 0s; }
.liveline-article > :nth-child(2) { animation-delay: 0.05s; }
.liveline-article > :nth-child(3) { animation-delay: 0.1s; }
.liveline-article > :nth-child(4) { animation-delay: 0.15s; }
.liveline-article > :nth-child(5) { animation-delay: 0.2s; }
.liveline-article > :nth-child(6) { animation-delay: 0.25s; }
.liveline-article > :nth-child(7) { animation-delay: 0.3s; }
.liveline-article > :nth-child(8) { animation-delay: 0.35s; }
.liveline-article > :nth-child(n + 9) { animation-delay: 0.4s; }
/* ========================================================================
Header fade gradient
======================================================================== */
.liveline-page::before {
content: "";
pointer-events: none;
z-index: 9;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 8rem;
background: linear-gradient(
rgb(255, 255, 255) 0px,
rgba(255, 255, 255, 0.737) 19%,
rgba(255, 255, 255, 0.541) 34%,
rgba(255, 255, 255, 0.382) 47%,
rgba(255, 255, 255, 0.278) 56.5%,
rgba(255, 255, 255, 0.194) 65%,
rgba(255, 255, 255, 0.126) 73%,
rgba(255, 255, 255, 0.075) 80.2%,
rgba(255, 255, 255, 0.042) 86.1%,
rgba(255, 255, 255, 0.021) 91%,
rgba(255, 255, 255, 0.008) 95.2%,
rgba(255, 255, 255, 0.002) 98.2%,
rgba(255, 255, 255, 0) 100%
);
}