← Back to site

Alpine.js Patterns

Alpine.js is loaded via the @astrojs/alpinejs integration and is available on all public pages. It provides lightweight client-side interactivity without the overhead of a full framework.

Grid Filtering

The events and projects listing pages share nearly identical Alpine.js logic for filtering, sorting, and paginating cards. The pattern lives inline in the x-data attribute of events.astro and projects.astro.

Data Pipeline

filteredIndices (getter)     →  Apply category + date/status filters, sort
  displayedIndices (getter)  →  Slice to visibleCount (pagination)
    visibleIndices (state)   →  Display buffer that animates in/out
  • filteredIndices — Computed getter that filters eventsData by active category and date filters, then sorts (upcoming events first, then by date within each group).
  • displayedIndices — Slices filteredIndices to visibleCount for pagination.
  • visibleIndices — Mutable state array that represents what’s currently rendered. Updated during animations.

Responsive Grid

Grid columns and items-per-page adjust dynamically on resize:

BreakpointColumnsItems per page
< 1024px13
1024px+24
1280px+36

getMinVisibleCount() ensures all upcoming items are visible and rows are always fully filled (rounds up to the nearest multiple of gridColumns).

URL Parameter Sync

On init(), the page reads URL parameters (?category=X,Y&date=upcoming,past&sort=oldest) and applies them to the filter state. This allows direct linking to filtered views.

Animation Patterns

Filter Change (_animateGrid)

When filters change, the grid animates in three phases:

  1. Fade out (150ms) — Grid opacity to 0
  2. Swap content — Lock height, update visibleIndices, measure new natural height
  3. Fade in + height animate (250ms) — Transition to new height while fading cards in with stagger (30ms per card)

Show More (showMore)

When “Show more” is clicked:

  1. Lock grid at current height
  2. Update visibleCount and visibleIndices
  3. Animate grid height to new natural height (300ms)
  4. Stagger new cards (30ms delay each) using showcaseCardIn keyframe

Generation Counter (_fadeGen)

Rapid clicks could cause overlapping animations. The _fadeGen counter increments on each animation start. Callbacks check if (gen !== this._fadeGen) return; to bail out if a newer animation has started.

transitionend + setTimeout Fallback

When listening for CSS transition end events, always pair with a setTimeout fallback and a boolean guard to prevent double execution:

let fadedOut = false;
const afterFadeOut = () => {
  if (fadedOut || gen !== this._fadeGen) return;
  fadedOut = true;
  // ... swap content
};

grid.addEventListener('transitionend', afterFadeOut, { once: true });
setTimeout(afterFadeOut, 200); // Fallback

x-teleport Gotchas

Never name an x-data property open. Inside x-teleport, Alpine’s scope resolution can match open to window.open (a built-in function), which is always truthy. This causes x-show="open" to always evaluate as true.

Use descriptive names instead:

<!-- Bad: "open" can resolve to window.open inside x-teleport -->
<div x-data="{ open: false }">
  <template x-teleport="body">
    <div x-show="open">...</div>
  </template>
</div>

<!-- Good: descriptive name avoids collision -->
<div x-data="{ modalOpen: false }">
  <template x-teleport="body">
    <div x-show="modalOpen">...</div>
  </template>
</div>

Grid Collapse Pattern

Collapsible sections (docs sidebar, accordion-like areas) use the grid-template-rows technique for smooth height animations:

<div
  class="grid transition-[grid-template-rows] duration-200 ease-out"
  x-bind:style="'grid-template-rows:' + (expanded ? '1fr' : '0fr')"
>
  <div class="overflow-hidden">
    <!-- collapsible content -->
  </div>
</div>

This approach avoids the need to measure scrollHeight and produces smooth CSS-driven animations.

localStorage Persistence

The docs sidebar uses localStorage to persist collapsed/expanded state:

<div
  x-data="{ expanded: JSON.parse(localStorage.getItem('key') ?? 'true') }"
  x-init="$watch('expanded', v => localStorage.setItem('key', JSON.stringify(v)))"
>

An inline <script> at the bottom of the sidebar reads localStorage on page load and sets grid-template-rows: 1fr (bypassing the transition) to prevent a flash of collapsed state.

x-cloak

Alpine-dependent content should use x-cloak to stay hidden until Alpine initializes:

<div x-cloak x-data="{ ... }">
  <!-- content that depends on Alpine state -->
</div>

The global CSS rule [x-cloak] { display: none !important; } in global.css hides these elements. Alpine automatically removes the x-cloak attribute once it processes the element.

The Header component integrates Pagefind for full-site search:

  • Debounced input — 150ms debounce on search input to avoid excessive searches
  • Keyboard navigation — Arrow keys, Enter to select, Escape to close
  • Global hotkeyCmd/Ctrl+K opens the search
  • Lazy loading — Pagefind library is loaded asynchronously on first search activation

prefers-reduced-motion

All Alpine.js animation code checks for reduced motion and skips animations when enabled:

if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  // Skip animation, apply state change directly
  this.visibleIndices = [...newIndices];
  return;
}

The Accordion component stores this as _reducedMotion in its Alpine state, checked before opening/closing panels.