Why Bother Surfacing Your RSS Feed?
Most developers add an RSS feed and then… do nothing with it. It sits at /rss.xml, the <link rel="alternate"> tag is in the <head>, and maybe there’s a tiny link in the footer. But nobody finds it.
RSS is making a quiet comeback. Feed readers like Feedly, Inoreader, and even Arc browser have RSS built in. Power readers — exactly the kind of people you want subscribing to a developer/builder blog — actively look for RSS feeds. If yours isn’t obvious, you’re leaving recurring readers on the table.
The goal here was simple: surface the RSS feed without being annoying about it. No modal, no banner, no popup. Just a tab that appears when the user looks for it — and when they hover, it gives them immediate value by showing the latest posts.
The Approach: Build-Time Data + Pure CSS
The key constraint I set for myself: no client-side JavaScript. Astro is a static site generator — the whole point is that you can compute things at build time and ship HTML. Fetching blog posts doesn’t need to happen in the browser; it can happen at build time using getCollection.
The CSS hover interaction also doesn’t need JS. A translateX transition on :hover and :focus-within is all you need.
Result: The drawer is static HTML with a CSS slide transition. No useEffect, no fetch, no hydration. It builds in under 14 seconds alongside 46 other pages and adds exactly zero bytes of JavaScript to the client.
Step 1 — Fetch Posts in BaseLayout.astro
Since the drawer appears on every page, BaseLayout.astro is the right place for it. Add getCollection to the frontmatter:
---
import { getCollection } from 'astro:content';
// ... your existing Props interface and destructuring ...
const latestPosts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 5);
---
This runs at build time for every page that uses BaseLayout. The filter excludes draft posts. The sort puts the newest first. The slice keeps it to 5 — enough to be useful, small enough to fit in the drawer without scrolling.
Important: getCollection is a build-time API in Astro. It reads from your src/content/ directory during the build and inlines the data into the HTML. By the time the browser sees the page, it’s just static HTML list items — no API call, no loading state.
Step 2 — The Drawer HTML
Place the drawer HTML between <nav> and <main>:
<!-- ── RSS HOVER DRAWER ── -->
<div class="rss-drawer" id="rss-drawer" role="complementary" aria-label="Latest blog posts">
<div class="rss-panel">
<div class="rss-panel-header">
<span class="rss-panel-title">Latest Posts</span>
<a href="/rss.xml" class="rss-feed-icon" title="Subscribe via RSS" aria-label="Subscribe via RSS">
<!-- RSS SVG icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/>
</svg>
</a>
</div>
<ul class="rss-post-list">
{latestPosts.map(post => (
<li class="rss-post-item">
<a href={`/blog/${post.data.slug || post.id}/`} class="rss-post-link">
<span class="rss-post-title">{post.data.title}</span>
<time class="rss-post-date" datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</time>
</a>
</li>
))}
</ul>
<a href="/rss.xml" class="rss-subscribe-btn">Subscribe via RSS</a>
</div>
<!-- The always-visible tab on the right side of the drawer -->
<div class="rss-tab" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/>
</svg>
<span class="rss-tab-label">RSS</span>
</div>
</div>
A few design decisions here worth noting:
role="complementary"— tells screen readers this is supplementary content, not the main page body.aria-hidden="true"on the tab — the tab is purely decorative; keyboard/screen reader users access the panel via:focus-within.post.data.slug || post.id— this is important in Astro content collections. If a post doesn’t have an explicitslugin its frontmatter, Astro uses the filename (without extension) as theid. Always use this fallback or you’ll get/blog/undefined/links.<time datetime="...">with a human-readable text node — correct semantic HTML for dates; helps search engines understand publication dates.
Step 3 — The CSS Transition
The entire open/close interaction is CSS. The drawer starts shifted left by 268px (its panel width) so only the 36px tab peeks out:
.rss-drawer {
position: fixed;
left: 0;
top: 50%;
transform: translateX(-268px) translateY(-50%);
z-index: 800;
display: flex;
flex-direction: row;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.rss-drawer:hover,
.rss-drawer:focus-within {
transform: translateX(0) translateY(-50%);
}
Key points:
translateX(-268px)— shifts the whole drawer left by the panel width, leaving only the tab (36px) visible. The math: panel is268pxwide, so shifting by-268pxhides the panel and leaves the tab flush with the viewport edge.translateY(-50%)— combined withtop: 50%, this vertically centers the drawer in the viewport regardless of drawer height.cubic-bezier(0.4, 0, 0.2, 1)— Material Design’s standard easing curve. Feels snappy without being jarring.:focus-within— makes the drawer work for keyboard users. When any element inside the drawer receives focus (a link, the subscribe button), the whole drawer stays open.z-index: 800— sits below most modals (z-index: 1000+) but above page content. Adjust if necessary for your nav.
The panel itself:
.rss-panel {
width: 268px;
background: var(--surface);
border: 1px solid var(--border-teal);
border-right: none;
border-radius: 0 0 var(--radius) 0;
padding: 1rem;
box-shadow: var(--shadow-teal), var(--shadow);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
border-right: none removes the border between the panel and the tab so they appear seamless.
The tab:
.rss-tab {
width: 36px;
background: var(--teal);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.75rem 0;
min-height: 80px;
color: #000;
box-shadow: 2px 0 12px rgba(0,212,255,0.25);
}
.rss-tab-label {
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
color: #000;
line-height: 1;
}
The writing-mode: vertical-rl with transform: rotate(180deg) rotates the “RSS” label to read bottom-to-top, which is the natural reading direction for vertical text on a left-side element.
Step 4 — Hide on Mobile
The drawer would cover content and have no good interaction model on touch devices, so just hide it entirely:
@media (max-width: 900px) {
.rss-drawer {
display: none;
}
}
Clean and simple. Mobile users still see the RSS link in the footer.
How It All Works Together
At build time:
- Astro runs
getCollection('blog')inBaseLayout.astro - It sorts posts by
pubDatedescending and takes the first 5 - Those posts are rendered as static
<li>elements — frozen HTML
At runtime in the browser:
- The drawer sits off-screen (only the tab is visible)
- User hovers over the tab → CSS
:hoverfires on.rss-drawer→transformtransitions totranslateX(0) - Panel slides in in 350ms
- User can click any post link or the subscribe button
- Mouse leaves → transition reverses
No JavaScript event listeners. No state management. No hydration budget consumed. The browser’s CSS engine handles everything.
Gotchas & Things I Learned
1. post.data.slug || post.id — always use the fallback
In Astro content collections, slug is optional in the schema. If a post doesn’t define it, post.data.slug is undefined and your links become /blog/undefined/. Always fall back to post.id which Astro derives from the filename.
2. position: fixed doesn’t respect z-index on transformed ancestors
If any ancestor element has a transform or will-change property, position: fixed children will position relative to that ancestor, not the viewport. Keep the drawer as a direct child of <body> (or at least outside any transformed containers).
3. border-right: none to merge panel and tab
The panel and tab are two separate elements laid out horizontally with flexbox. Removing the right border on the panel prevents a double-border where they meet.
4. translateX value must exactly equal the panel width
If the panel renders wider than 268px (e.g., due to padding or long post titles), the tab won’t sit exactly at the viewport edge. Either constrain the panel width strictly or calculate the offset dynamically. I use width: 268px and max-width: 268px on the panel to keep this deterministic.
5. getCollection in a layout runs on every page build
This is intentional and correct — Astro caches the content collection in memory during a build, so it doesn’t re-read files on every page. The overhead is negligible.
6. focus-within for keyboard accessibility
Without :focus-within, keyboard users who tab into the drawer would be interacting with invisible links. Adding :focus-within alongside :hover keeps the drawer open while keyboard focus is anywhere inside it.
SEO Value of Surfacing RSS
Beyond UX, there are indirect SEO benefits to making your RSS feed discoverable:
- Crawler discoverability — Google and Bing follow RSS links. A prominently linked
/rss.xmlgives crawlers another signal about your content update frequency. - Internal linking — The drawer’s post list creates additional internal links on every page, reinforcing the authority of your most recent posts to search engines.
- User engagement — RSS subscribers are high-intent readers. Lower bounce rate (they actually read) and return visits signal quality to search engines.
<time datetime>markup — The semantic date tags in the drawer help search engines understand recency, which factors into freshness ranking for time-sensitive queries.
None of these are silver bullets, but they compound over time with zero ongoing maintenance cost.
Full Code Reference
If you ever need to re-implement this from scratch, here’s the complete checklist:
BaseLayout.astro frontmatter
import { getCollection } from 'astro:content';
const latestPosts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 5);
HTML placement
<nav>...</nav>
<div class="rss-drawer">...</div> ← insert here
<main><slot /></main>
CSS summary
| Selector | Purpose |
|---|---|
.rss-drawer | position: fixed; left: 0; top: 50%; transform: translateX(-268px) translateY(-50%) |
.rss-drawer:hover, :focus-within | transform: translateX(0) translateY(-50%) |
.rss-panel | width: 268px; background; border; flex column |
.rss-tab | width: 36px; background: teal; border-radius right side only |
.rss-tab-label | writing-mode: vertical-rl; transform: rotate(180deg) |
@media (max-width: 900px) | display: none |
File checklist
-
src/layouts/BaseLayout.astro— addgetCollectionimport +latestPostsquery + drawer HTML -
public/assets/css/styles.css— add drawer CSS section -
npm run build— verify 0 errors, page count unchanged
Total implementation time: ~30 minutes. Total client-side JavaScript added: 0 bytes.
Comments
Loading comments...
Leave a Comment