Marketing

BlogList

Posts as cards in a grid or rows down the page: picture, tags, title, excerpt, author and date.

#marketing#list#card#page#no-js

Demo

Preview

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.qwe
main.qwe
import 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.

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

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

Thread 0 posts

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

No posts yet. The first one opens the thread.