News

LiveBlog

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.

#news#live#feed#time#interactive

Demo

Preview
Live11 updatesUpdated 2 min ago

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.

  1. Key eventSenate

    Ohio Senate race called

    The Ledger's decision desk calls Ohio for the challenger, the first seat to change hands tonight.

    Ada Lovelace, decision desk

  2. Turnout in the capital is running about 6% ahead of the last midterm, the elections office says.

  3. House

    Three House seats in the suburbs are too close to call with about 70% of the expected vote counted.

  4. Key eventGovernor

    Governor concedes

    The incumbent governor has conceded in a short statement outside her campaign office.

    Grace Hopper, in Columbus

  5. Lines are still long at some polling places; anyone in line at closing time can still vote.

Get it

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.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/live_blog.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/live_blog.qwe
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}/>

Source

ui/live_blog.qwe
// 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>}
Thread
88Useful? Like it, or add to its thread below.

Thread 0 posts

On LiveBlog: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.