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.
Declare what you use
Section titled “Declare what you use”"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 no-JS rule (read this twice)
Section titled “The no-JS rule (read this twice)”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; }Reduced motion is shared work
Section titled “Reduced motion is shared work”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.
Carousel: static or alive is your call
Section titled “Carousel: static or alive is your call”<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-counton 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-progresson 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-elapsedlands on expiry.data-p60-accordionon 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-headeron your header getsis-condensedpast a small scroll (addauto-hidefor the hide-on-scroll-down pair). The engine only toggles classes; the look is yours.data-p60-sticky-ctaon a bar addsis-stuckpast 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-lightboxon a gallery whosedata-p60-lightbox-itemanchors 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-lightboxparts.data-p60-tabswithdata-p60-tab="key"controls anddata-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-progresson 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 withdata-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 carrydata-p60-show-more-item, with adata-p60-show-more-toggle<button>inside the list (or pointing at it witharia-controls): the engine hides the items past the count withhidden, so no-JS visitors see the whole list, and keepsaria-expandedhonest. 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-scrollspyon a menu ofhref="#id"links: the link for the section being read getsis-currentandaria-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.
Lifecycle, and a limit to know about
Section titled “Lifecycle, and a limit to know about”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.
Navigation: your menu, made robust
Section titled “Navigation: your menu, made robust”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.
In the local preview
Section titled “In the local preview”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.
What belongs where
Section titled “What belongs where”- 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.

