RelatedPosts
Read next: other posts at the end of one, those sharing the most tags first, with picture, title, excerpt, date and the shared tags marked.
Demo
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/related_posts.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/related_posts.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/related_posts.qwe
let related_posts_demo_pic = (a: str, b: str) => `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 200'%3E%3Crect width='320' height='200' fill='${a}'/%3E%3Ccircle cx='90' cy='80' r='40' fill='${b}'/%3E%3Crect x='150' y='110' width='130' height='60' rx='8' fill='white' fill-opacity='.35'/%3E%3C/svg%3E`let related_posts_demo_posts = [ { title: "One file to deploy", href: "#related_posts-deploy", tags: ["Ops"], date: "2026-07-21", read_time: "6 min read", excerpt: "A binary and a program, copied to a server. What we learned running it for a year.", image: related_posts_demo_pic("%23a35200", "%23f3b54a") }, { title: "Styles that travel with their components", href: "#related_posts-styles", tags: ["CSS", "Quillo"], date: "2026-09-02", read_time: "5 min read", excerpt: "How each component brings its own sheet, and why a page only gets what it shows.", image: related_posts_demo_pic("%235b4bdb", "%23b9a8ff"), image_alt: "Two shapes on purple" }, { title: "Streaming the slow parts of a page", href: "#related_posts-defer", tags: ["Quillo", "Performance"], date: "2026-10-02", read_time: "7 min read", excerpt: "The page goes out at once; the reviews follow in the same answer.", image: related_posts_demo_pic("%2318794e", "%234cc38a") }, { title: "Week notes: 43", href: "#related_posts-wn43", tags: ["Week notes"], date: "2026-10-24" },]view RelatedPostsDemo() = <div.demo-stack style="gap: 36px"> <RelatedPosts id="related-posts-demo" tags={["quillo", "css"]} posts={related_posts_demo_posts}/> <div style="max-width: 460px"> <RelatedPosts id="related-posts-demo-list" title="More from the blog" layout="list" limit={2} posts={[related_posts_demo_posts[3], related_posts_demo_posts[2]]}/> </div> </div>Source
ui/related_posts.qwe
// RelatedPosts: "Read next" at the end of a post: a few other posts, those that share// the most tags with this one first, each with its picture, title, a line of it, the// date and reading time, and the tags it shares with this post marked.//// <RelatedPosts tags={["quillo", "css"]} posts={[// { title: "Styles that travel", href: "/blog/styles", tags: ["css", "quillo"], date: "2026-09-02",// image: "/img/styles.jpg", read_time: "5 min read", excerpt: "How a component brings its own CSS." },// { title: "One file to deploy", href: "/blog/deploy", tags: ["ops"], date: "2026-07-21" },// ]}/>//// posts: maps of { title, href, excerpt?, image?, image_alt?, date?, read_time?, tags? };// date an ISO date, shown as "Sep 2, 2026". tags: this post's tags: posts sharing more of// them come first (else the order given is kept), and the shared ones are marked. limit:// how many to show (3). title: the heading ("Read next"; "" for none). layout: "grid"// (default, cards side by side as fit at 220px or more) or "list" (rows, a small picture// at the side). locale: how dates are written ("en-US"). id: the <section>'s id// ("related-posts"); the heading's is id + "-title".import ui/theme.qwe use ui_clslet related_posts_css = css`.ui-brel { display: grid; gap: 16px; margin: 0; font: 15px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-brel .ui-brel-title { margin: 0; font-size: 20px; font-weight: 700; }.ui-brel-items { display: grid; gap: 20px; margin: 0; padding: 0; list-style: none; }.ui-brel-grid .ui-brel-items { grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }.ui-brel-item { position: relative; display: flex; flex-direction: column; gap: 8px; min-width: 0; }.ui-brel-pic { display: block; overflow: hidden; aspect-ratio: 16 / 10; border-radius: var(--ui-radius); background: var(--ui-subtle); }.ui-brel-pic img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .3s; }.ui-brel-item:hover .ui-brel-pic img { transform: scale(1.03); }.ui-brel-blank { display: grid; place-items: center; color: var(--ui-strong); }.ui-brel-blank svg { width: 32px; height: 32px; }.ui-brel-text { display: grid; gap: 4px; min-width: 0; }.ui-brel .ui-brel-name { margin: 0; font-size: 16px; font-weight: 650; line-height: 1.3; }/* The title's link covers the whole card. */.ui-brel-name a { color: inherit; text-decoration: none; }.ui-brel-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--ui-radius); }.ui-brel-item:hover .ui-brel-name a { color: var(--ui-accent); }.ui-brel-name a:focus-visible { outline: none; }.ui-brel-name a:focus-visible::after { box-shadow: var(--ui-focus); }.ui-brel-excerpt { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-brel-meta { display: flex; flex-wrap: wrap; gap: 0 8px; margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-brel-meta > * + *::before { content: "·"; margin-right: 8px; }.ui-brel-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 2px 0 0; padding: 0; list-style: none; }.ui-brel-tags li { padding: 0 8px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; line-height: 20px; }.ui-brel-tags li.ui-brel-shared { background: var(--ui-accent-soft); color: var(--ui-accent); font-weight: 600; }.ui-brel-list .ui-brel-items { gap: 14px; }.ui-brel-list .ui-brel-item { flex-direction: row; align-items: center; gap: 14px; }.ui-brel-list .ui-brel-pic { flex: none; width: 112px; aspect-ratio: 4 / 3; border-radius: var(--ui-radius-sm); }.ui-brel-list .ui-brel-excerpt { -webkit-line-clamp: 1; }@media (prefers-reduced-motion: reduce) { .ui-brel-pic img { transition: none; } }`// The tags of a post that this one shares, in small letters.fn related_posts_shared(post_tags: list?, mine: list) = (post_tags ?? []) | filter((t) => contains(mine, lower(t)))view RelatedPosts(posts: list, tags: list? = null, limit: int = 3, title: str = "Read next", layout: str = "grid", locale: str = "en-US", id: str = "related-posts", class: str? = null) { style global related_posts_css let mine = (tags ?? []) | map((t) => lower(t)) let ranked = range(len(posts)) | map((i) => { i, n: len(related_posts_shared(posts[i].tags, mine)) }) | sort_by((r) => r.i - r.n * 1000) | map((r) => posts[r.i]) let shown = take(ranked, limit) <section id={id} class={ui_cls(["ui-brel", `ui-brel-${layout}`, class])} aria-labelledby={title != "" ? `${id}-title` : null} aria-label={title == "" ? "Related posts" : null}> {title != "" && <h2.ui-brel-title id={`${id}-title`}>{title}</h2>} <ul.ui-brel-items> {for p in shown => { let shared = related_posts_shared(p.tags, mine) <li.ui-brel-item key={p.href} data-shared={len(shared)}> {p.image != null ? <span.ui-brel-pic><img src={p.image} alt={p.image_alt ?? ""} loading="lazy"/></span> : (layout == "list" ? <span.ui-brel-pic.ui-brel-blank aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/> </svg> </span> : null)} <div.ui-brel-text> <h3.ui-brel-name><a href={p.href}>{p.title}</a></h3> {p.excerpt != null && <p.ui-brel-excerpt>{p.excerpt}</p>} {(p.date != null || p.read_time != null) && <p.ui-brel-meta> {p.date != null && <time datetime={str(p.date)}>{date.valid(str(p.date)) ? date.format(slice(str(p.date), 0, 10), { locale, style: "medium" }) : str(p.date)}</time>} {p.read_time != null && <span>{p.read_time}</span>} </p>} {p.tags != null && len(p.tags) > 0 && <ul.ui-brel-tags aria-label="Tags"> {for t in p.tags => contains(mine, lower(t)) ? <li.ui-brel-shared>{t}<span.ui-sr> (shared with this post)</span></li> : <li>{t}</li>} </ul>} </div> </li> }} </ul> </section>}Related
AuthorBioThe box at the end of a post about its author: picture or initials, name and role, a short bio, links elsewhere, their post count and a follow link.8 views · 9 downloadsBlogrollThe blogs a writer recommends, in groups: name, a line about each, its latest post and how long ago, its feed, and the list as OPML.9 views · 8 downloadsPostArchiveA blog's posts by year and month, newest first: each year a disclosure with its count, its months and their posts, or only the months as links.10 views · 8 downloadsSeriesNavWhere a post stands in a series: Part 2 of 5, a bar of the parts, the list of them all with this one marked, and the parts before and after.9 views · 7 downloadsRoadmapColumns (Now, Next, Later) of items with a status badge, tags, a target date and votes; side by side, stacked where narrow.8 views · 6 downloadsProductGridProducts as ProductCards in a grid that fits as many across as there is room for, from a list of records.6 views · 5 downloads
Thread
Thread 0 posts
On RelatedPosts: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.