Blogging

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.

#blogging#card#list#recommendations#no-js

Demo

Preview

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

Thread 0 posts

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

No posts yet. The first one opens the thread.