Persistent Filter Bar with View Transitions
WEB PLATFORMS

Persistent Filter Bar with View Transitions

Personal Project
FRONTEND DEVELOPER
Dec 2024 – Present

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 ExpectationCommon Failure Mode
Scroll position persistsResets to left on each page
Hover state continues through clickDrops and re-applies, causing flicker
Page depth maintained when filteringJumps to top on every filter change
Click feels responsiveNo 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

StateStandard TransitionWith persist
scrollLeft positionResets to 0Maintained
:hover pseudo-classLost on swapMaintained
Ongoing CSS animationsInterruptedContinue
Event listenersRequire re-bindingAlready 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 explicitly
nav.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:

ConcernStatic RoutesClient-Side
SEOCategory pages are indexableRequires additional configuration
ShareabilityURLs are meaningful and shareableQuery params or hash fragments
JS DependencyWorks without JavaScriptRequires JavaScript
Initial LoadEach category pre-renderedFilter logic runs client-side
View TransitionsNatural integrationManual animation handling

View Transitions make static routes feel as fluid as client-side filtering while preserving the architectural benefits.