
Persistent Filter Bar with View Transitions
SEAMLESS CATEGORY FILTERING WITH PROFESSIONAL POLISH—NO SCROLL JUMPS, NO HOVER FLICKER.
Persistent Filter Bar with View Transitions
What Makes a Filter Bar Feel Professional
Category filter bars appear simple but require careful attention to preserve user context during navigation:
| User Expectation | Common Failure Mode |
|---|---|
| Scroll position persists | Resets to left on each page |
| Hover state continues through click | Drops and re-applies, causing flicker |
| Page depth maintained when filtering | Jumps to top on every filter change |
| Click feels responsive | No feedback during navigation delay |
This guide covers the patterns required to deliver a polished filtering experience with Astro’s View Transitions API.
Pattern 1: DOM Persistence with transition:persist
The standard View Transition behavior swaps DOM elements—capturing a snapshot of the old element, replacing it with the new one, and crossfading between them. For interactive components like filter bars, this breaks continuity.
Use transition:persist to keep the actual DOM element alive across navigations:
<nav id="work-category-nav" transition:name="work-category-nav" transition:persist class="flex flex-nowrap gap-3 overflow-x-auto"> {categories.map(cat => ( <a href={`/work/category/${slugify(cat)}`} class={cat === activeCategory ? 'btn-accent' : 'btn-outline'}> {cat} </a> ))}</nav>What Persistence Preserves
| State | Standard Transition | With persist |
|---|---|---|
scrollLeft position | Resets to 0 | Maintained |
:hover pseudo-class | Lost on swap | Maintained |
| Ongoing CSS animations | Interrupted | Continue |
| Event listeners | Require re-binding | Already bound |
Required Trade-off: Manual State Sync
Persisted elements bypass server rendering on navigation. The active button styling must be updated via JavaScript:
document.addEventListener('astro:after-swap', () => { const nav = document.getElementById('work-category-nav'); const currentPath = window.location.pathname;
nav?.querySelectorAll('a').forEach(btn => { const href = btn.getAttribute('href'); const isActive = href === currentPath || (currentPath === '/work' && href === '/work');
btn.classList.toggle('btn-accent', isActive); btn.classList.toggle('btn-outline', !isActive); btn.toggleAttribute('aria-current', isActive); });});Pattern 2: The Delta-Restoration Scroll Strategy
Even without transition:persist, scroll positions require explicit handling. Astro resets scroll to the top during page transitions.
Horizontal Scroll: Always Restore
The filter bar’s scrollLeft should be restored on every navigation—whether switching filters or returning from a detail page:
const SCROLL_KEY = 'filterScrollPos';
document.addEventListener('astro:before-preparation', () => { const nav = document.getElementById('work-category-nav'); if (nav) sessionStorage.setItem(SCROLL_KEY, nav.scrollLeft);});
document.addEventListener('astro:after-swap', () => { const nav = document.getElementById('work-category-nav'); const saved = sessionStorage.getItem(SCROLL_KEY); if (nav && saved) nav.scrollLeft = parseInt(saved, 10);});Vertical Scroll: Conditional Restoration
Vertical page scroll requires a different approach. Browser history navigation (back/forward) has its own scroll restoration—overriding it creates a worse experience.
The solution: only restore vertical scroll for explicit filter clicks, not all navigations.
const PAGE_SCROLL_KEY = 'pageVerticalScroll';const NAV_FLAG_KEY = 'isFilterNavigation';
// Mark filter clicks explicitlynav.addEventListener('click', (e) => { if (e.target.closest('a')) { sessionStorage.setItem(NAV_FLAG_KEY, 'true'); sessionStorage.setItem(PAGE_SCROLL_KEY, window.scrollY); }});
document.addEventListener('astro:after-swap', () => { const isFilterNav = sessionStorage.getItem(NAV_FLAG_KEY) === 'true'; sessionStorage.removeItem(NAV_FLAG_KEY);
if (isFilterNav) { const saved = sessionStorage.getItem(PAGE_SCROLL_KEY); if (saved) window.scrollTo(0, parseInt(saved, 10)); } // Browser handles history navigation scroll automatically});Pattern 3: Tactile Click Feedback
View transitions introduce a delay between click and visible change. Without feedback, the interface feels unresponsive. Add a brief pulse animation on click:
@keyframes filter-btn-pulse { 0% { transform: scale(1); } 50% { transform: scale(0.95); filter: brightness(1.1); } 100% { transform: scale(1); }}
.filter-btn-animate { animation: filter-btn-pulse 0.2s ease-out;}nav.addEventListener('click', (e) => { const link = e.target.closest('a'); if (link) { link.classList.add('filter-btn-animate'); link.addEventListener('animationend', () => { link.classList.remove('filter-btn-animate'); }, { once: true }); }});The animation completes regardless of navigation timing, providing immediate acknowledgment.
Pattern 4: Scroll Affordance with Edge Fades
Long filter lists require overflow scrolling. Gradient fades at the edges signal that more content exists:
<div class="relative"> <div id="filter-fade-left" class="absolute left-0 w-12 h-full bg-gradient-to-r from-base-100 to-transparent pointer-events-none z-10" transition:animate="none" style="opacity: 0" />
<nav id="work-category-nav">...</nav>
<div id="filter-fade-right" class="absolute right-0 w-12 h-full bg-gradient-to-l from-base-100 to-transparent pointer-events-none z-10" transition:animate="none" style="opacity: 1" /></div>Update visibility based on scroll position:
function updateFades() { const nav = document.getElementById('work-category-nav'); const fadeLeft = document.getElementById('filter-fade-left'); const fadeRight = document.getElementById('filter-fade-right');
const { scrollLeft, scrollWidth, clientWidth } = nav; const maxScroll = scrollWidth - clientWidth;
fadeLeft.style.opacity = scrollLeft > 5 ? '1' : '0'; fadeRight.style.opacity = scrollLeft < maxScroll - 5 ? '1' : '0';}
nav.addEventListener('scroll', updateFades);Note the transition:animate="none" on fade elements—this prevents them from fading in during page transitions when they should already be visible.
Architecture: Static Routes vs Client-Side Filtering
This implementation uses static routes (/work/category/automation-ai) rather than client-side filtering. The trade-offs favor static routing for most use cases:
| Concern | Static Routes | Client-Side |
|---|---|---|
| SEO | Category pages are indexable | Requires additional configuration |
| Shareability | URLs are meaningful and shareable | Query params or hash fragments |
| JS Dependency | Works without JavaScript | Requires JavaScript |
| Initial Load | Each category pre-rendered | Filter logic runs client-side |
| View Transitions | Natural integration | Manual animation handling |
View Transitions make static routes feel as fluid as client-side filtering while preserving the architectural benefits.
Related
- Building an Astro Portfolio with AI-Assisted Development — Parent project overview
- Navbar Active State Morphing — Shared element transitions for navigation