Overview
The core theme ships no JavaScript framework dependency. All interactive behavior uses self-contained vanilla JavaScript following Starwind-pattern conventions. Each component owns its DOM discovery, state management, event handling, and lifecycle cleanup.
Core Conventions
Component Part Identification — data-slot
Components use data-slot attributes to identify meaningful DOM parts. Scripts discover their elements via data-slot selectors rather than relying on class names or DOM structure.
<div data-slot="accordion">
<button data-slot="accordion-trigger">...</button>
<div data-slot="accordion-panel">...</div>
</div>
State Management — data-state
Interactive state is tracked via data-state attributes. CSS transitions and visibility rules target these selectors. The base CSS rule [data-slot][data-state="closed"] { display: none !important } provides FOUC prevention for elements that should be hidden on initial render (modals, drawers, tooltips). The !important is intentional: it ensures the closed state still wins when the element also carries utility classes such as flex, grid, or block.
Elements that use a different visibility mechanism (such as CSS grid-template-rows for accordion collapse) should avoid combining data-slot and data-state="closed" on the same element to prevent the base rule from interfering.
Instance Storage — WeakMap
Component handler instances are stored in a WeakMap<HTMLElement, Handler>. This is GC-friendly — when the DOM element is removed, the handler instance becomes eligible for garbage collection without manual cleanup.
const instances = new WeakMap<HTMLElement, MyHandler>();
function setup() {
document
.querySelectorAll<HTMLElement>('[data-slot="my-component"]')
.forEach((el) => {
if (instances.has(el)) return; // prevent double-init
instances.set(el, new MyHandler(el));
});
}
TypeScript Classes for Medium/Complex Components
Medium and complex components use TypeScript classes that encapsulate state, event handling, and DOM manipulation. Simple behaviors (e.g., a toggle button) may use plain functions.
Lifecycle Hooks — View Transitions Compatibility
Each component initializes on both initial page load and SPA navigation:
setup()runs at module scope for the initial loaddocument.addEventListener("astro:page-load", setup)handles SPA re-navigation- The
WeakMap.has(el)guard prevents double-initialization on persisted elements
Event Listener Cleanup — AbortController
Window/document event listeners registered per component instance use AbortController for cleanup. Each instance creates an AbortController on init and passes { signal } to addEventListener. On astro:before-swap, the controller is aborted, removing all associated listeners in one call.
class MyHandler {
private ac = new AbortController();
constructor(el: HTMLElement) {
window.addEventListener("scroll", this.onScroll, {
passive: true,
signal: this.ac.signal,
});
}
destroy() {
this.ac.abort();
}
}
// Cleanup before view transition swap
document.addEventListener("astro:before-swap", cleanup);
Singleton listeners (e.g., global keyboard shortcuts) register once at module scope with a guard, not per-instance.
Progressive Enhancement
All core content and navigation links are accessible without JavaScript. Interactive enhancements require JS. Components render in a sensible default state without JS — accordion items expanded, navigation links visible, modals hidden via data-state="closed".
Modal Overlays — Native <dialog>
Modal overlays (search modal, lightbox, drawer) use the native HTML <dialog> element with .showModal(), which provides built-in focus trapping, scroll lock, Escape key dismissal, and ::backdrop styling. Exit animations use JS-managed timing — set data-state="closed", wait for the CSS transition duration, then call .close().
Collapse Animations — CSS grid-template-rows
Expandable content (accordions, collapsible sections) uses CSS grid-template-rows: 0fr / 1fr transitions. The JS responsibility is limited to toggling the state attribute; the animation is purely CSS.