Pinned
What we know so far
Polls have closed in 31 states. The Senate is the race to watch: each party needs four of the seven toss-ups to hold a majority.
We will call races only when our decision desk is sure.
A live blog: timestamped updates with a pinned summary on top, key events that jump to their entry, key-events-only and oldest-first switches, and paging.
Pinned
Polls have closed in 31 states. The Senate is the race to watch: each party needs four of the seven toss-ups to hold a majority.
We will call races only when our decision desk is sure.
Key eventSenate
The Ledger's decision desk calls Ohio for the challenger, the first seat to change hands tonight.
Ada Lovelace, decision desk
Turnout in the capital is running about 6% ahead of the last midterm, the elections office says.
House
Three House seats in the suburbs are too close to call with about 70% of the expected vote counted.
Key eventGovernor
The incumbent governor has conceded in a short statement outside her campaign office.
Grace Hopper, in Columbus
Lines are still long at some polling places; anyone in line at closing time can still vote.
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/live_blog.qweimport ui/theme.qwe use theme_cssuse ui/live_blog.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>The code of the preview above.
let live_blog_demo_entries = [ { id: "pin", pinned: true, time: "19:05", title: "What we know so far", body: "Polls have closed in 31 states. The Senate is the race to watch: each party needs four of the seven toss-ups to hold a majority.\n\nWe will call races only when our decision desk is sure." }, { id: "u11", time: "21:48", datetime: "2026-11-03T21:48:00-05:00", key: true, tag: "Senate", title: "Ohio Senate race called", body: "The Ledger's decision desk calls Ohio for the challenger, the first seat to change hands tonight.", author: "Ada Lovelace, decision desk" }, { id: "u10", time: "21:40", body: "Turnout in the capital is running about 6% ahead of the last midterm, the elections office says." }, { id: "u9", time: "21:31", tag: "House", body: "Three House seats in the suburbs are too close to call with about 70% of the expected vote counted." }, { id: "u8", time: "21:15", datetime: "2026-11-03T21:15:00-05:00", key: true, tag: "Governor", title: "Governor concedes", body: "The incumbent governor has conceded in a short statement outside her campaign office.", author: "Grace Hopper, in Columbus" }, { id: "u7", time: "20:58", body: "Lines are still long at some polling places; anyone in line at closing time can still vote." }, { id: "u6", time: "20:40", tag: "Analysis", body: "Early returns lean towards the incumbents, but the mail-in ballots, which are counted later, may shift that." }, { id: "u5", time: "20:22", body: "A judge has kept three precincts open an hour longer after their machines broke down this morning." }, { id: "u4", time: "20:00", datetime: "2026-11-03T20:00:00-05:00", key: true, title: "Polls close in 16 more states", body: "Polls are now closed in most of the East and the Midwest." }, { id: "u3", time: "19:30", body: "Our reporters are at the campaigns' parties in six states tonight." }, { id: "u2", time: "19:12", body: "The first counties have started to report their results." }, { id: "u1", time: "19:00", datetime: "2026-11-03T19:00:00-05:00", title: "Welcome", body: "Welcome to our live coverage of election night." },]view LiveBlogDemo() = <LiveBlog id="live-blog-demo" title="Election night live" updated="Updated 2 min ago" page_size={5} entries={live_blog_demo_entries}/>// LiveBlog: a story told as it happens: timestamped updates, newest first, with a// pinned summary held at the top, the key events listed as links that jump to them,// a switch between all updates and the key events only, the order turned round// (oldest first, to read it as a story), and the older updates a page at a time.//// <LiveBlog id="vote" status="live" updated="Updated 2 min ago" entries={[// { id: "p1", pinned: true, time: "21:05", title: "What we know", body: "Polls have closed in 12 states..." },// { id: "u9", time: "21:42", datetime: "2026-11-03T21:42:00-05:00", key: true, title: "Ohio called",// body: "The Ledger's decision desk calls Ohio.", author: "Ada Lovelace", tag: "Senate" },// { id: "u8", time: "21:30", body: "Turnout in the capital is running ahead of 2022." },// ]}/>//// entries: newest first, maps of { id, time, body, datetime?, title?, author?, tag?,// key?, pinned? }: id is unique among them (the entry's element is `<id>-<entry id>`,// a link can point at it); time: when, as it reads ("21:42", "2 min ago"), with// datetime its ISO form; body: the text, a blank line between paragraphs; title: a// heading for it; author: who wrote it; tag: a small label ("Senate", "Analysis");// key: a key event (marked, listed at the top and kept by "Key events"); pinned:// shown at the top, out of the stream (the first pinned one). id (required): unique on// the page. status: "live" (default, a pulsing Live) or "ended". updated: a line// beside it ("Updated 2 min ago"). title: the blog's name for screen readers ("Live// updates"). page_size: the updates shown before "Show more" (10). oldest_first: start// with the oldest at the top. Entry headings are <h3>.import ui/theme.qwe use ui_clslet live_blog_css = css`.ui-lvb { display: grid; gap: 16px; min-width: 0; max-width: 720px; color: var(--ui-fg); font: 15px/1.55 var(--ui-font); }.ui-lvb-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; color: var(--ui-muted); font-size: 13px; }.ui-lvb-status { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 4px; background: var(--ui-danger); color: var(--ui-bg); font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }.ui-lvb-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: ui-lvb-pulse 1.4s ease-in-out infinite; }@keyframes ui-lvb-pulse { 50% { opacity: .2; } }.ui-lvb-ended .ui-lvb-status { background: var(--ui-subtle); color: var(--ui-muted); }.ui-lvb-ended .ui-lvb-status::before { display: none; }.ui-lvb-keys { padding: 12px 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-subtle); }.ui-lvb-keys h3 { margin: 0 0 6px; font-size: 13px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--ui-muted); }.ui-lvb-keys ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-lvb-keys a { display: grid; grid-template-columns: 52px 1fr; gap: 8px; color: var(--ui-fg); text-decoration: none; font-size: 14px; }.ui-lvb-keys a span { color: var(--ui-danger); font-weight: 700; font-variant-numeric: tabular-nums; }.ui-lvb-keys a:hover b { color: var(--ui-accent); text-decoration: underline; }.ui-lvb-keys b { font-weight: 550; }.ui-lvb-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }.ui-lvb-seg { display: inline-flex; padding: 3px; border-radius: 999px; background: var(--ui-subtle); }.ui-lvb-seg button, .ui-lvb-order { height: 30px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--ui-muted); font: 600 13px var(--ui-font); cursor: pointer; }.ui-lvb-seg button[aria-pressed="true"] { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .1); }.ui-lvb-order { border: 1px solid var(--ui-border); }.ui-lvb-order:hover, .ui-lvb-seg button:hover { color: var(--ui-fg); }.ui-lvb button:focus-visible, .ui-lvb a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-lvb-feed { margin: 0; padding: 0; list-style: none; }.ui-lvb-entry { position: relative; display: grid; gap: 6px; padding: 16px 0 18px 22px; border-top: 1px solid var(--ui-border); scroll-margin-top: 80px; }.ui-lvb-entry::before { content: ""; position: absolute; left: 0; top: 23px; width: 9px; height: 9px; border-radius: 50%; background: var(--ui-strong); }.ui-lvb-entry:focus { outline: none; }.ui-lvb-entry:focus-visible { box-shadow: var(--ui-focus); border-radius: var(--ui-radius-sm); }.ui-lvb-key::before { background: var(--ui-danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-danger) 25%, transparent); }.ui-lvb-pinned { padding: 14px 16px 16px; border: 1px solid var(--ui-border); border-left: 4px solid var(--ui-accent); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-lvb-pinned::before { display: none; }.ui-lvb-top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; font-size: 13px; }.ui-lvb-time { color: var(--ui-fg); font-weight: 700; font-variant-numeric: tabular-nums; }.ui-lvb-flag { padding: 0 7px; border-radius: 4px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }.ui-lvb-flag-key { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-lvb-flag-pin { background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-lvb-tag { color: var(--ui-muted); }.ui-lvb .ui-lvb-title { margin: 0; font-size: 18px; line-height: 1.3; font-weight: 700; }.ui-lvb-entry > p:not(.ui-lvb-top):not(.ui-lvb-by) { margin: 0; }.ui-lvb-by { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-lvb-empty { padding: 16px 0; color: var(--ui-muted); border-top: 1px solid var(--ui-border); }.ui-lvb-more { justify-self: center; height: 38px; padding: 0 20px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-lvb-more:hover { border-color: var(--ui-accent); color: var(--ui-accent); }@media (prefers-reduced-motion: reduce) { .ui-lvb-status::before { animation: none; } }`component LiveBlog(entries: list, id: str, status: str = "live", updated: str? = null, title: str = "Live updates", page_size: int = 10, oldest_first: bool = false, class: str? = null) { style global live_blog_css state only_key = false state oldest = oldest_first state shown = page_size state jump = null let pin = entries | find((e) => e.pinned == true) let stream = entries | filter((e) => pin == null || e.id != pin.id) let picked = only_key ? stream | filter((e) => e.key == true) : stream let ordered = oldest ? reverse(picked) : picked let visible = take(ordered, shown) let left = len(ordered) - len(visible) let keys = stream | filter((e) => e.key == true) let anchor = (e) => `${id}-${e.id}` // A key event's link: show the entry (it may be on a later page), then go to it. let go = (e) => { let at = index_of(ordered | map((x) => x.id), e.id) if at == null { only_key = false let all = oldest ? reverse(stream) : stream shown = max([shown, (index_of(all | map((x) => x.id), e.id) ?? 0) + 1]) } else if at >= shown { shown = at + 1 } jump = anchor(e) } if jump != null { every(16, () => { let el = js("document.getElementById", jump) jump = null if el != null { el.scrollIntoView({ block: "start", behavior: "smooth" }) el.focus({ preventScroll: true }) } }) } let body = (e) => for p in split(e.body ?? "", "\n\n") | filter((p) => trim(p) != "") => <p>{trim(p)}</p> let entry = (e, pinned: bool) => [ <p.ui-lvb-top> <time.ui-lvb-time datetime={e.datetime}>{e.time}</time> {pinned && <span class="ui-lvb-flag ui-lvb-flag-pin">Pinned</span>} {e.key == true && <span class="ui-lvb-flag ui-lvb-flag-key">Key event</span>} {e.tag != null && <span.ui-lvb-tag>{e.tag}</span>} </p>, e.title != null && <h3.ui-lvb-title>{e.title}</h3>, body(e), e.author != null && <p.ui-lvb-by>{e.author}</p>, ] <section class={ui_cls(["ui-lvb", status == "ended" && "ui-lvb-ended", class])} id={id} aria-label={title}> <div.ui-lvb-head> <span.ui-lvb-status>{status == "ended" ? "Ended" : "Live"}</span> <span data-count={len(stream)}>{len(stream)} {len(stream) == 1 ? "update" : "updates"}</span> {updated != null && <span>{updated}</span>} </div> {pin != null && <article class="ui-lvb-entry ui-lvb-pinned" id={anchor(pin)} tabindex="-1">{entry(pin, true)}</article>} {len(keys) > 0 && <nav.ui-lvb-keys aria-label="Key events"> <h3>Key events</h3> <ul> {for e in keys => <li key={e.id}> <a href={`#${anchor(e)}`} @click={(ev) => { ev.preventDefault() go(e) }}><span>{e.time}</span><b>{e.title ?? e.body}</b></a> </li>} </ul> </nav>} <div.ui-lvb-tools> <div.ui-lvb-seg role="group" aria-label="Show"> <button type="button" aria-pressed={only_key ? "false" : "true"} @click={() => { only_key = false }}>All updates</button> <button type="button" aria-pressed={only_key ? "true" : "false"} @click={() => { only_key = true }}>Key events</button> </div> <button.ui-lvb-order type="button" @click={() => { oldest = !oldest }} aria-label={oldest ? "Oldest first; show the newest first" : "Newest first; show the oldest first"}> {oldest ? "Oldest first ↑" : "Newest first ↓"} </button> </div> <ol.ui-lvb-feed> {for e in visible => <li key={e.id}> <article class={ui_cls(["ui-lvb-entry", e.key == true && "ui-lvb-key"])} id={anchor(e)} tabindex="-1">{entry(e, false)}</article> </li>} </ol> {len(visible) == 0 && <p.ui-lvb-empty>{only_key ? "No key events yet." : "No updates yet."}</p>} {left > 0 && <button.ui-lvb-more type="button" @click={() => { shown += page_size }}> Show {left < page_size ? left : page_size} more {left == 1 ? "update" : "updates"} </button>} </section>}On LiveBlog: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.