Skip to content

Motion and behaviour

Templates never ship JavaScript. That rule is enforced by the validator: a <script> tag, an inline event handler or a javascript: URL in any .liquid file fails validation before your template goes anywhere. And yet a Port60 template can breathe: content that reveals as the visitor scrolls, numbers that count up, heroes that rotate, FAQs that fold smoothly.

The mechanism is the behaviour catalogue. You author complete, honest, static markup and opt into behaviour with a data-p60-* attribute; the platform engine finds the attribute after render and attaches the JavaScript, the accessibility wiring and the reduced-motion handling. You decide whether a hero is static or a carousel by writing markup. The full grammar for every behaviour is in the behaviour catalogue reference.

"supports": {
"behaviors": ["reveal", "carousel"]
}

The validator proves declaration and usage agree in both directions: declaring a behaviour whose attributes never appear is an error, and using the attributes without declaring is too.

The engine adds the class p60-js to the root element the moment its script runs. Any CSS that hides content before a behaviour shows it must be scoped under .p60-js. A visitor without JavaScript then sees your complete static page: all slides stacked, every reveal already visible. Never hide content unconditionally and rely on a script to show it.

/* ✓ pre-reveal state only exists when the engine is there to finish the story */
.p60-js .my-cards li { opacity: 0; transform: translateY(0.6rem); transition: opacity 480ms, transform 480ms; }
.p60-js .my-cards.is-revealed li { opacity: 1; transform: none; }
/* ✗ never this, a no-JS visitor gets a blank section */
.my-cards li { opacity: 0; }

The runtime honours prefers-reduced-motion on its side: reveals land instantly, counters show the final number, progress bars render at their final width, carousels stop auto-advancing. The transitions in your CSS are yours, so gate every motion rule on the visitor allowing it:

@media (prefers-reduced-motion: no-preference) {
.p60-js .my-cards li { transition: opacity 480ms, transform 480ms; }
}

Reveal: the highest alive-per-effort ratio

Section titled “Reveal: the highest alive-per-effort ratio”
<ol class="my-cards" data-p60-reveal-group>
{% for item in section.items %}<li></li>{% endfor %}
</ol>

The engine stamps --p60-reveal-index on each child and adds is-revealed when the group enters the viewport. Your CSS keys the stagger off the index:

.p60-js .my-cards li { transition-delay: calc(var(--p60-reveal-index, 0) * 90ms); }

Taste guidance: subtle rise (half a rem), under half a second, staggered under 100ms per item. The Stripe feel comes from restraint, not amplitude. Reduced-motion visitors get everything visible immediately; you never have to handle that yourself.

<div class="my-hero-slides" data-p60-carousel="auto">
{% for image in section.images %}
<img data-p60-slide {% if forloop.first %}class="is-active"{% endif %} src="{{ image.imageUrl }}" alt="{{ image.alt }}">
{% endfor %}
</div>

The engine manages is-active, injects a dots nav (.p60-carousel-dots, yours to style), wires swipe and keyboard arrows, hides inactive slides from assistive tech, and only auto-advances when you said auto and the visitor allows motion. With auto it also injects a rotation control (.p60-carousel-pause, first in the carousel’s tab order, .p60-carousel-pause--stopped while stopped) and follows the WAI-ARIA carousel pattern: hovering pauses and leaving resumes, keyboard focus entering the carousel stops rotation and leaving does not restart it, and the control is the only way back. is-paused sits on your container while rotation is stopped, so you can style that state. Style the control; the engine gives it only an accessible label. Class-based fade on is-active is the simplest CSS pattern; the engine also stamps --p60-carousel-index for track-translate designs. The starter template’s standard hero is a worked example, and its vista framings show the other route: the ready-made hero_carousel island. Both are legitimate; the island is zero-effort; the behaviour is yours to design.

Counters, progress, countdowns, accordions

Section titled “Counters, progress, countdowns, accordions”
  • data-p60-count on the element that holds the figure, typed exactly as it should read: £20, 95%, 1,200, 2.5k. Only the digits count up on first reveal; prefix, suffix, grouping and decimal places stay as typed and the final frame restores the text exactly. Nothing to format.
  • data-p60-progress on a bar whose authored width is the target: it sweeps in on reveal.
  • data-p60-countdown data-p60-until="2026-09-01T18:00:00Z": static date text becomes a live remainder, is-elapsed lands on expiry.
  • data-p60-accordion on a group of native <details>: exclusive-open plus smooth height, with working no-JS semantics for free.

Structure and chrome: sticky, lightbox, tabs

Section titled “Structure and chrome: sticky, lightbox, tabs”
  • data-p60-sticky-header on your header gets is-condensed past a small scroll (add auto-hide for the hide-on-scroll-down pair). The engine only toggles classes; the look is yours.
  • data-p60-sticky-cta on a bar adds is-stuck past the threshold. The bar must duplicate an action already on the page, which is why it is the one element allowed to hide unconditionally in CSS: a no-JS visitor loses nothing.
  • data-p60-lightbox on a gallery whose data-p60-lightbox-item anchors point at the full images: the no-JS click is a plain navigation, the enhanced click is an injected overlay with keyboard, captions and a focus trap. Style the injected .p60-lightbox parts.
  • data-p60-tabs with data-p60-tab="key" controls and data-p60-panel="key" panels: the engine wires the roles, arrows and roving focus, and hides inactive panels itself, so the no-JS render shows everything stacked with no CSS scoping needed.

Reading, browsing and finding your place: reading progress, show more, scrollspy

Section titled “Reading, browsing and finding your place: reading progress, show more, scrollspy”
  • data-p60-reading-progress on a bar gets --p60-reading-progress (0 to 1) and a progressbar role kept honest by the engine. Draw the bar from the variable (width: calc(var(--p60-reading-progress) * 100%)). Name the element the reading covers with data-p60-reading-target="article-body", or leave it off for the whole document. It keeps updating under reduced motion: position is information. The bar is pure enhancement, so it may be hidden unconditionally in CSS.
  • data-p60-show-more="6" on a list whose entries carry data-p60-show-more-item, with a data-p60-show-more-toggle <button> inside the list (or pointing at it with aria-controls): the engine hides the items past the count with hidden, so no-JS visitors see the whole list, and keeps aria-expanded honest. Give the toggle a value (data-p60-show-more-toggle="Show fewer") and its text swaps while expanded. A list with nothing to hide hides its toggle. Show the toggle only under .p60-js.
  • data-p60-scrollspy on a menu of href="#id" links: the link for the section being read gets is-current and aria-current="location". The value is the activation line in pixels from the top (default 96). Links keep navigating, nothing moves focus, and links whose target is not on the page are ignored.

Behaviours initialise once per page load, after render, and the runtime stamps each wired element with data-p60-bound-<behaviour> so a second pass never binds twice. Pages are full documents: there is no teardown step, because nothing swaps template markup in place, and the appearance editor reloads its preview frame rather than patching it.

Reveal fires when about fifteen percent of an element is inside the viewport. An element at least a viewport tall fires the moment any of it appears instead, so a long section can never stay hidden waiting for a ratio it cannot reach. Reveal sections and cards all the same, and prefer animating on is-revealed over hiding until it arrives: content that is visible by default and moves when the class lands can never be lost.

Each behaviour initialises on its own. If one throws, the engine reports it on the console and the others still wire, so a mistake in one part of a page never switches off the menu.

You design the menu: the markup, every pixel, the breakpoint at which it stacks. CSS :hover alone cannot make it reliable, though: the pointer leaves the group in the gap between a toggle and its panel and the menu vanishes on the way to a link, touch has no hover, and Escape and outside clicks need script. The nav behaviour supplies exactly that interaction and nothing else. It injects no markup and styles nothing; it toggles is-open and keeps aria-expanded honest.

<button class="my-burger" data-p60-nav-burger aria-controls="site-nav" aria-expanded="false">Menu</button>
<nav id="site-nav" aria-label="Primary" data-p60-nav>
<div class="my-group" data-p60-nav-item>
<a href="{{ item.href }}" data-p60-nav-toggle aria-expanded="false">{{ item.label }}</a>
<div class="my-panel" data-p60-nav-menu></div>
</div>
</nav>

What the engine does on an item: hover-capable pointers get hover intent, a short delay before opening and a grace period before closing, so crossing the gap never closes the panel. Keyboard, touch and pen toggle on the control: Enter or a tap opens without navigating, again closes, ArrowDown opens and focuses the first link. A mouse click on a parent link whose menu is already open by hover navigates, as a link should. Escape closes and returns focus to the toggle. A click outside, or focus moving to another element, closes. One item stays open per level; nested items and several independent nav roots are fine. The burger toggles is-open on itself and on the panel it names, closes on Escape with focus returned, on a real navigation inside the panel, on an outside click, and when it disappears at a wider viewport.

Your CSS keys off is-open under .p60-js, and keeps hover for the no-JS render:

.my-panel { display: none; }
.my-group.is-open > .my-panel,
html:not(.p60-js) .my-group:hover > .my-panel,
html:not(.p60-js) .my-group:focus-within > .my-panel { display: block; }
@media (max-width: 1080px) {
.p60-js #site-nav:not(.is-open) { display: none; }
}

Crowded headers. When the nav’s row wraps or overflows before your burger breakpoint, the engine steps is-fit-1, is-fit-2 and is-fit-3 onto the nav root, re-measuring after each, until the row fits, and clears them again when there is room. The platform’s own sign-in pill already uses the steps: the Port60 ID wordmark goes at step one and the text at step two, leaving the person icon with its accessible label. Map the same classes to whatever you would rather give up before collapsing into the burger: gaps, a smaller nav font, a shorter label.

.my-nav.is-fit-2 { gap: .35rem .85rem; }
.my-nav.is-fit-3 { font-size: .88em; }

The older chrome hooks (data-nav-burger, #site-nav, data-nav-drop) keep working for templates that have not moved yet; they only cover the burger and taps inside the mobile panel.

p60-template-kit dev inlines the same runtime the platform ships, so every behaviour on this page works locally, menus and burger built on the nav behaviour included. Only the older chrome hooks stay inert in the preview; check those on a live site or a development version of your own site.

  • Your CSS already owns hover effects, transitions, sticky positioning and scroll snap: no behaviour needed, no declaration, full freedom.
  • Behaviours are for what genuinely needs JavaScript but carries no data: observers, timers, input handling on your own markup.
  • Islands are for anything data-bearing or transactional. If it needs tenant data or takes money, it is an island: you place it rather than build it.