Component System
JS components are auto-loaded from src/js/components/ based on
data-component attributes in HTML. All components extend a base
Component class.
HTML attributes
Attach a component to any element with data-component. The value must match the
filename in src/js/components/.
<!-- Basic component -->
<div data-component="MyComponent">...</div>
<!-- With options -->
<div data-component="Slider" data-options='{"speed": 500}'>...</div>
<!-- Responsive: only init on medium+ -->
<div data-component="Sidebar" data-media="mediumUp">...</div>
<!-- Persistent: survives Swup page transitions -->
<nav data-component="NavList" data-persistent="true">...</nav>
Refs
Use data-ref to reference child elements. Single elements become a direct
reference, multiple same-name refs become an array.
<div data-component="Tabs">
<button data-ref="trigger">Tab 1</button>
<button data-ref="trigger">Tab 2</button>
<div data-ref="content">Panel 1</div>
<div data-ref="content">Panel 2</div>
</div>
// In JS:
// this.ref.trigger → [button, button]
// this.ref.content → [div, div]
Creating a component
Create a file in src/js/components/ matching the data-component name.
// src/js/components/Accordion.js
import Component from '../core/Component'
export default class Accordion extends Component {
constructor(element, options = {}) {
super(element, options)
}
prepare() {
// Called after construction
// this.element — the root DOM element
// this.ref — data-ref elements
// this.options — parsed from data-options
this.ref.trigger.forEach(btn => {
btn.addEventListener('click', this.toggle)
})
}
toggle(e) {
// Methods are auto-bound to `this`
const index = this.ref.trigger.indexOf(e.currentTarget)
this.ref.content[index].classList.toggle('is-open')
}
destroy() {
// Cleanup: remove listeners, observers, etc.
this.ref.trigger.forEach(btn => {
btn.removeEventListener('click', this.toggle)
})
}
}
Lifecycle
Every component goes through three phases managed by
src/js/core/componentUtils.js.
constructor— refs are auto-initialized, methods auto-boundprepare()— add event listeners, set up observers, fetch datadestroy()— clean up on page transition or viewport mismatch
How it works
1. Page loads (or Swup transition fires)
2. componentUtils scans for [data-component] elements
3. Each component JS file is dynamically imported
4. constructor() runs → refs parsed, methods bound
5. prepare() runs → your init code
6. On page leave: destroy() runs for non-persistent components
Responsive components
Components with data-media are automatically destroyed when the viewport no longer
matches and re-initialized when it does. A resize listener handles this.
Available breakpoint keys
Defined in src/js/core/config.js, mirroring the Stylus breakpoints:
smallUp, smallMax, smallWideUp, smallWideMax,
mediumUp, mediumMax, largeUp, largeMax,
xlargeUp, xlargeMax, xxlargeUp, xxlargeMax,
xxxlargeUp
Example
<!-- Sidebar only initializes at 768px+ -->
<aside data-component="Sidebar" data-media="mediumUp">
...
</aside>
<!-- Tooltip disabled on mobile -->
<div data-component="Tooltip" data-media="largeUp">
...
</div>
Persistence
By default, components are destroyed and re-initialized on every Swup page transition. Mark a
component as data-persistent="true" to keep it alive across navigations.
<!-- NavList stays alive across page transitions -->
<nav data-component="NavList" data-persistent="true">
...
</nav>
Persistent components must live outside the #swup container (or in a shared partial
like header.html) so they remain in the DOM after transitions.
File structure
src/js/
app.js // Entry — Swup + Lenis setup
core/
Component.js // Base class (refs, lifecycle, auto-bind)
componentUtils.js // Dynamic loading, responsive init, persistence
config.js // Breakpoints mirrored from Stylus
components/
NavList.js // Navigation with active state
LucideGrid.js // Lucide icon loader
YourComponent.js // Add new components here
utils/
dom.js // DOM helpers (query, queryAll)
Quick start
- Create
src/js/components/MyComponent.js - Add
data-component="MyComponent"to your HTML element - Implement
prepare()anddestroy() - It auto-loads — no imports or registration needed