BlogList
Posts as cards in a grid or rows down the page: picture, tags, title, excerpt, author and date.
Demo
Quillo 2.0: live queries, typed records and a debugger
The biggest release yet. A page can now follow a query as rows change, records are checked at the door, and you can step through a route.
How a page is resumed, not rebuilt
The browser takes the server's elements as its own. Here is what that means for forms, scrolling and images.
One file to deploy
A binary and a program, copied to a server. What we learned running it for a year.
Quillo 2.0: live queries, typed records and a debugger
The biggest release yet. A page can now follow a query as rows change, records are checked at the door, and you can step through a route.
How a page is resumed, not rebuilt
The browser takes the server's elements as its own. Here is what that means for forms, scrolling and images.
Get it
It needs theme.qwe, as every component does, and avatar.qwe; 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/avatar.qwe$ curl -O https://quillolang.org/components/ui/blog_list.qweimport ui/theme.qwe use theme_cssuse ui/blog_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let blog_list_demo_pic = (a: str) => `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 180'%3E%3Crect width='320' height='180' fill='${a}'/%3E%3Ccircle cx='250' cy='60' r='28' fill='white' fill-opacity='.5'/%3E%3Cpath d='M0 180 L110 90 L180 140 L240 100 L320 160 V180z' fill='white' fill-opacity='.35'/%3E%3C/svg%3E`let blog_list_demo_posts = [ { title: "Quillo 2.0: live queries, typed records and a debugger", href: "#blog_list", excerpt: "The biggest release yet. A page can now follow a query as rows change, records are checked at the door, and you can step through a route.", date: "2026-10-14", tags: ["Release"], image: blog_list_demo_pic("%235b4bdb"), author: "Ada Lovelace", read_time: "4 min read" }, { title: "How a page is resumed, not rebuilt", href: "#blog_list", excerpt: "The browser takes the server's elements as its own. Here is what that means for forms, scrolling and images.", date: "2026-09-02", tags: ["Engineering", "Deep dive"], image: blog_list_demo_pic("%2318794e"), author: "Grace Hopper", read_time: "9 min read" }, { title: "One file to deploy", href: "#blog_list", excerpt: "A binary and a program, copied to a server. What we learned running it for a year.", date: "2026-07-21", tags: ["Ops"], image: blog_list_demo_pic("%23a35200"), author: "Margaret Hamilton" },]view BlogListDemo() = <div.demo-stack> <BlogList id="blog-list-demo" posts={blog_list_demo_posts} tag_href="#blog_list-tag-"/> <BlogList layout="rows" posts={take(blog_list_demo_posts, 2)}/> </div>Source
// BlogList: posts as cards in a grid, or as rows down the page, each with a// picture, its tags, the title, a line of the text, who wrote it and when.//// <BlogList posts={[// { title: "Quillo 2.0", href: "/blog/2-0", excerpt: "Live queries, typed records, a debugger.",// date: "2026-10-14", tags: ["Release"], image: "/img/2-0.png", author: "Ada", read_time: "4 min" },// ]}/>// <BlogList layout="rows" posts={posts} tag_href="/blog/tag/"/>//// posts: a list of { title, href, excerpt?, date?, tags?, image?, image_alt?,// author?, author_src?, read_time? }. date: an ISO date ("2026-10-14"), shown as// "Oct 14, 2026" in a <time>; other text is shown as it is. tags: short words;// with tag_href each is a link to that address plus the tag ("/blog/tag/" +// "Release"). author and author_src: the writer's name and picture (drawn with// Avatar, so the page needs ui/avatar.qwe). layout: "cards" (default: a grid,// as many columns as fit at min pixels wide, 280) or "rows" (one under another,// the picture to the side). The title is a link to href; the picture is too.import ui/avatar.qwe use Avatarimport ui/theme.qwe use ui_clslet blog_list_css = css`.ui-blog { display: grid; gap: 24px; margin: 0; padding: 0; list-style: none; font: 15px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-blog-cards { grid-template-columns: repeat(auto-fill, minmax(min(var(--ui-blog-min, 280px), 100%), 1fr)); }.ui-blog-post { position: relative; display: flex; flex-direction: column; gap: 10px; min-width: 0; }.ui-blog-rows .ui-blog-post { flex-direction: row-reverse; gap: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--ui-border); }.ui-blog-rows .ui-blog-post:last-child { padding-bottom: 0; border-bottom: 0; }.ui-blog-pic { display: block; overflow: hidden; border-radius: var(--ui-radius); background: var(--ui-subtle); aspect-ratio: 16 / 9; }.ui-blog-pic img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .3s; }.ui-blog-pic:hover img { transform: scale(1.03); }.ui-blog-pic:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-blog-rows .ui-blog-pic { flex: none; width: 220px; aspect-ratio: 4 / 3; }.ui-blog-body { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }.ui-blog-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-blog-tag { display: inline-block; padding: 2px 9px; border-radius: 999px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 12px; font-weight: 600; text-decoration: none; }a.ui-blog-tag:hover { text-decoration: underline; }a.ui-blog-tag:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-blog-title { margin: 0; font-size: 18px; font-weight: 650; line-height: 1.3; }.ui-blog-title a { color: inherit; text-decoration: none; border-radius: 3px; }.ui-blog-title a:hover { color: var(--ui-accent); }.ui-blog-title a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-blog-excerpt { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; margin: 0; color: var(--ui-muted); }.ui-blog-meta { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 6px; color: var(--ui-muted); font-size: 13px; }.ui-blog-meta time, .ui-blog-meta span { white-space: nowrap; }.ui-blog-author { color: var(--ui-fg); font-weight: 500; }.ui-blog-dot::before { content: "·"; }@media (max-width: 640px) { .ui-blog-rows .ui-blog-post { flex-direction: column; gap: 10px; } .ui-blog-rows .ui-blog-pic { width: auto; aspect-ratio: 16 / 9; }}@media (prefers-reduced-motion: reduce) { .ui-blog-pic img { transition: none; } }`// An ISO date as "Oct 14, 2026"; anything else as it is.fn blog_list_date(d) -> str = d != null && date.valid(str(d)) ? date.format(slice(str(d), 0, 10), "MMM D, YYYY") : str(d ?? "")view BlogList(posts: list, layout: str = "cards", min: int = 280, tag_href: str? = null, id: str? = null, class: str? = null) { style global blog_list_css <ul id={id} class={ui_cls(["ui-blog", `ui-blog-${layout}`, class])} style={layout == "cards" ? `--ui-blog-min: ${min}px` : null}> {for p in posts => <li.ui-blog-post> {p.image != null && <a.ui-blog-pic href={p.href} tabindex="-1" aria-hidden="true"> <img src={p.image} alt={p.image_alt ?? ""} loading="lazy"/> </a>} <div.ui-blog-body> {p.tags != null && len(p.tags) > 0 && <ul.ui-blog-tags aria-label="Tags"> {for t in p.tags => <li>{tag_href != null ? <a.ui-blog-tag href={`${tag_href}${url_encode(t)}`}>{t}</a> : <span.ui-blog-tag>{t}</span>}</li>} </ul>} <h3.ui-blog-title><a href={p.href}>{p.title}</a></h3> {p.excerpt != null && <p.ui-blog-excerpt>{p.excerpt}</p>} {(p.author != null || p.date != null || p.read_time != null) && <div.ui-blog-meta> {p.author != null && <Avatar name={p.author} src={p.author_src} size="xs"/>} {p.author != null && <span.ui-blog-author>{p.author}</span>} {p.author != null && p.date != null && <span.ui-blog-dot aria-hidden="true"></span>} {p.date != null && <time datetime={str(p.date)}>{blog_list_date(p.date)}</time>} {p.read_time != null && (p.author != null || p.date != null) && <span.ui-blog-dot aria-hidden="true"></span>} {p.read_time != null && <span>{p.read_time}</span>} </div>} </div> </li>} </ul>}Related
Thread 0 posts
On BlogList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.