Behaviour catalogue
The behaviour catalogue: engine-attached JavaScript a template opts into with data attributes on its own markup. Templates never ship code; they author complete static markup and declare behaviours in supports.behaviors. The engine adds the p60-js class to the root element when its script runs; any CSS that hides pre-behaviour content MUST be scoped under .p60-js so the no-JS render stays complete. All behaviours honour prefers-reduced-motion centrally. State classes are toggled by the engine; their appearance is entirely the template’s CSS. Each entry’s primaryAttribute is the opt-in attribute: the validator derives its declaration checks from it and the reference documents it, so the catalogue is the single source.
| Behaviour | Status | Label |
|---|---|---|
reveal |
✅ available | Reveal on scroll |
counter |
✅ available | Animated counter |
progress |
✅ available | Progress bar sweep |
countdown |
✅ available | Countdown |
accordion |
✅ available | Accordion |
stickyHeader |
✅ available | Sticky header condense |
stickyCta |
✅ available | Sticky call-to-action bar |
lightbox |
✅ available | Lightbox |
tabs |
✅ available | Tabs |
carousel |
✅ available | Carousel |
readingProgress |
✅ available | Reading progress |
showMore |
✅ available | Show more and less |
scrollspy |
✅ available | Scrollspy |
nav |
✅ available | Navigation |
reveal
Section titled “reveal”Reveal on scroll
Adds is-revealed when the element first enters the viewport; the template’s CSS does the actual motion. data-p60-reveal-group on a container staggers its children by stamping –p60-reveal-index on each (use it in a transition-delay calc). Reduced motion: is-revealed applies immediately on load.
Opt in with data-p60-reveal on your own markup and declare reveal in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-reveal |
optional variant name for the CSS to key on |
data-p60-reveal-group |
none; staggers direct children |
State classes (engine-toggled; the appearance is your CSS): is-revealed
CSS variables the engine stamps: --p60-reveal-index
counter
Section titled “counter”Animated counter
Counts the element’s figure up from zero when first revealed. The static text IS the final value, typed exactly as it should read (“£20”, “95%”, “1,200”, “2.5k”): the engine animates only the first run of digits and keeps the prefix, suffix, grouping and decimal places as typed, restoring the exact text on the last frame. A numeric attribute value (legacy) overrides the target; the text still sets the format. Reduced motion: the final figure shows immediately.
Opt in with data-p60-count on your own markup and declare counter in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-count |
none: the element’s own text is the target and the format; or a numeric target (legacy) |
data-p60-count-duration |
optional ms, capped at 4000 (default 1200) |
State classes (engine-toggled; the appearance is your CSS): is-revealed
progress
Section titled “progress”Progress bar sweep
Animates the marked element’s width from zero to its authored width when first revealed. The authored width (inline style or CSS) is the target and the no-JS render. Reduced motion: the authored width stands untouched.
Opt in with data-p60-progress on your own markup and declare progress in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-progress |
none; the element’s own width is the target |
State classes (engine-toggled; the appearance is your CSS): is-revealed
countdown
Section titled “countdown”Countdown
Replaces the element’s content with a live compact remainder to data-p60-until (“3d 12h 45m”). The static content is the authored date text (the no-JS render). On expiry the engine adds is-elapsed and swaps to data-p60-elapsed-text when provided. Countdowns keep ticking under reduced motion: they are information, not decoration. v1 unit labels are compact and language-neutral (d/h/m/s).
Opt in with data-p60-countdown on your own markup and declare countdown in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-countdown |
segments: dhms, dhm (default) or hm |
data-p60-until |
ISO-8601 instant, required |
data-p60-elapsed-text |
optional text shown once elapsed |
State classes (engine-toggled; the appearance is your CSS): is-elapsed
accordion
Section titled “accordion”Accordion
On a container of native details elements: exclusive-open (opening one closes the others) plus smooth height animation. data-p60-accordion=“multi” keeps independent opening. Built on details/summary so the no-JS render is already functional and semantics are free. Reduced motion: no height animation, instant toggle.
Opt in with data-p60-accordion on your own markup and declare accordion in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-accordion |
none for exclusive-open; “multi” for independent |
State classes (engine-toggled; the appearance is your CSS): is-open
stickyHeader
Section titled “stickyHeader”Sticky header condense
State classes for scroll-aware headers: past a small threshold the engine adds is-condensed; with the “auto-hide” value, scrolling down past 160px adds is-hidden and scrolling up removes it. The engine only ever toggles classes (rAF-throttled), what condensing or hiding looks like, and whether it animates, is entirely the template’s CSS (gate transitions behind prefers-reduced-motion yourself; the classes always apply because a condensed header is layout, not decoration).
Opt in with data-p60-sticky-header on your own markup and declare stickyHeader in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-sticky-header |
none for condense-only; “auto-hide” adds the hide-on-scroll-down pair |
State classes (engine-toggled; the appearance is your CSS): is-condensed, is-hidden
stickyCta
Section titled “stickyCta”Sticky call-to-action bar
Adds is-stuck once the visitor scrolls past the threshold (the attribute value in px, default 480) and removes it above. The bar should DUPLICATE an action that already exists in the page (a donate button, a ticket link); because of that, this is the one behaviour whose element may be hidden unconditionally in CSS rather than .p60-js-scoped: a no-JS visitor loses nothing; the original action is still on the page.
Opt in with data-p60-sticky-cta on your own markup and declare stickyCta in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-sticky-cta |
optional scroll threshold in px (default 480) |
State classes (engine-toggled; the appearance is your CSS): is-stuck
lightbox
Section titled “lightbox”Lightbox
On a group container: each data-p60-lightbox-item is an anchor whose href is the full image (the no-JS render simply navigates to it, already functional). The engine intercepts the click and opens an injected overlay (.p60-lightbox) with the image, a caption from data-p60-caption or the thumbnail’s alt, a close button, backdrop and Escape close, arrow-key prev/next within the group, and a focus trap. Reduced motion: no transition on the overlay (the engine sets none; style the steady state).
Opt in with data-p60-lightbox on your own markup and declare lightbox in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-lightbox |
none; the group container |
data-p60-lightbox-item |
none; on each anchor to a full image |
data-p60-caption |
optional caption text on an item |
State classes (engine-toggled; the appearance is your CSS): is-open
Injected markup you may style: .p60-lightbox, .p60-lightbox-img, .p60-lightbox-caption, .p60-lightbox-close, .p60-lightbox-prev, .p60-lightbox-next
Tabs
data-p60-tab=“key” on each tab control and data-p60-panel=“key” on each panel, inside a data-p60-tabs container. The engine wires the tablist/tab/tabpanel roles, aria-selected, roving tabindex, arrow/Home/End keys, toggles is-active on the active pair and hides inactive panels with the hidden attribute, so the no-JS render shows every panel stacked and complete, with no CSS scoping needed. The first tab (or the one authored with is-active) starts active.
Opt in with data-p60-tabs on your own markup and declare tabs in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-tabs |
none; the container |
data-p60-tab |
the key of the panel this control activates |
data-p60-panel |
the key matching its control |
State classes (engine-toggled; the appearance is your CSS): is-active
carousel
Section titled “carousel”Carousel
On a container with one data-p60-slide per child: the engine manages is-active across slides, injects a dots nav (.p60-carousel-dots with one button per slide), wires optional template-provided data-p60-carousel-prev/data-p60-carousel-next controls, adds swipe, keyboard arrows, and hides inactive slides from assistive tech. Value “auto” enables auto-advance (data-p60-interval ms, min 3000, default 6000) and injects a rotation control (.p60-carousel-pause, first in the carousel’s tab order) following 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 the carousel while rotation is stopped. Reduced motion disables auto-advance and the control while every other control keeps working. The engine stamps –p60-carousel-index on the container for track-translate designs; class-based fade on is-active is the simplest pattern. CSS hiding non-active slides MUST be .p60-js-scoped so the no-JS render shows all slides.
Opt in with data-p60-carousel on your own markup and declare carousel in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-carousel |
none for manual; “auto” for auto-advance |
data-p60-slide |
none; one per slide child |
data-p60-interval |
optional auto-advance ms, min 3000 (default 6000) |
data-p60-carousel-prev |
none; optional template-provided control |
data-p60-carousel-next |
none; optional template-provided control |
State classes (engine-toggled; the appearance is your CSS): is-active, is-paused
CSS variables the engine stamps: --p60-carousel-index
Injected markup you may style: .p60-carousel-dots, .p60-carousel-dot, .p60-carousel-dot--active, .p60-carousel-pause, .p60-carousel-pause--stopped
readingProgress
Section titled “readingProgress”Reading progress
A reading-position indicator. The engine writes –p60-reading-progress (0 to 1) on the marked element and keeps a progressbar role honest (aria-valuenow 0 to 100, an accessible name of “Reading progress” unless the template gives one); the bar’s shape and motion are the template’s CSS. With data-p60-reading-target naming an element’s id the reading is that element’s extent, otherwise the whole document; a target the page does not have leaves the bar static. is-started once past zero, is-complete at the end. Information, not decoration: it keeps updating under reduced motion. The bar is pure enhancement, so it may be hidden unconditionally in CSS: a no-JS visitor loses nothing.
Opt in with data-p60-reading-progress on your own markup and declare readingProgress in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-reading-progress |
none; the indicator element |
data-p60-reading-target |
optional id of the element whose extent is the reading (default: the document) |
State classes (engine-toggled; the appearance is your CSS): is-started, is-complete
CSS variables the engine stamps: --p60-reading-progress
showMore
Section titled “showMore”Show more and less
A list that starts short. On a container whose entries carry data-p60-show-more-item and which holds (or is named by aria-controls from) a data-p60-show-more-toggle button: the engine hides every item past the visible count with the hidden attribute, so the no-JS render shows the complete list and needs no CSS scoping; the toggle expands and collapses, aria-expanded stays honest, is-expanded sits on the container, and a toggle whose attribute carries a value swaps its text to that value while expanded. A list with nothing to hide hides its toggle: there is never a dead control. Author the toggle to show only under .p60-js.
Opt in with data-p60-show-more on your own markup and declare showMore in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-show-more |
optional number of items shown while collapsed (default 6) |
data-p60-show-more-item |
none; on each entry |
data-p60-show-more-toggle |
none, or the label to show while expanded; on a button inside the list or pointing at it with aria-controls |
State classes (engine-toggled; the appearance is your CSS): is-expanded
scrollspy
Section titled “scrollspy”Scrollspy
On a menu of in-page links (href=“#id”): the link whose section is being read gets is-current and aria-current=“location”, where the current section is the target furthest down the page whose top has passed the activation line (the attribute value in px from the top of the viewport, default 96). Links keep navigating and nothing ever moves focus; links whose target is not on the page are ignored, and a menu with no targets is left alone. Information, not decoration: it keeps updating under reduced motion.
Opt in with data-p60-scrollspy on your own markup and declare scrollspy in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-scrollspy |
none, or the activation line in px (default 96); on the menu |
State classes (engine-toggled; the appearance is your CSS): is-current
Navigation
Your menu, made robust. You design the menu (markup, every pixel, the breakpoint); the engine supplies the interaction CSS :hover cannot: data-p60-nav on each nav root (several allowed), data-p60-nav-item on each group holding a data-p60-nav-toggle control and a data-p60-nav-menu panel (nesting allowed). Hover-capable pointers get hover intent (a short delay to open, a grace period to close) so crossing the gap between a toggle and its panel never closes it; keyboard, touch and pen toggle on the control (Enter/click, ArrowDown opens and focuses the first link; a mouse click on an already-hovered parent link navigates); Escape closes and returns focus to the toggle; a click outside or focus moving away closes; one item stays open per level and aria-expanded is kept honest. The burger: data-p60-nav-burger on a button whose aria-controls (or the attribute value) names the stacked panel; the engine toggles is-open on both, closes on Escape (returning focus), on a real navigation inside the panel, on an outside click, and when the burger disappears at a wider viewport. State is is-open on the item, the panel and the burger; under .p60-js show a panel only on is-open and keep your :hover/:focus-within rules scoped to html:not(.p60-js) for the no-JS render. Fit steps: when the root’s row wraps or overflows, the engine steps is-fit-1, is-fit-2, is-fit-3 onto the root until it fits, re-measured on resize and once fonts settle, never on a stacked (column) nav; the platform’s defaults shrink the member_menu pill at each step (the Port60 ID wordmark, then the text, leaving the person icon with its accessible label), and a template maps the same classes to anything else it wants to give up before the burger.
Opt in with data-p60-nav on your own markup and declare nav in supports.behaviors.
| Attribute | Value |
|---|---|
data-p60-nav |
none; a nav root, several allowed |
data-p60-nav-item |
none; a group holding one toggle and one menu, nestable |
data-p60-nav-toggle |
none; the item’s control (a link or button) |
data-p60-nav-menu |
none; the item’s panel |
data-p60-nav-burger |
none, or the id of the panel when aria-controls is absent |
State classes (engine-toggled; the appearance is your CSS): is-open, is-fit-1, is-fit-2, is-fit-3
Declare what you use in supports.behaviors, the validator proves declaration and usage agree in both directions. Remember the no-JS rule: any CSS hiding pre-behaviour content must be scoped under .p60-js.

