Side Panel example
How it works
A row of panels where one is open and the rest collapse to their title. Click a title and the open one trades places with it.
Side Panel lays out a set of panels side by side. One is open and shows its content; the others shrink to a vertical title. Click a title and the panels swap: the open one collapses, the clicked one grows. Below the mobile breakpoint the row becomes a column and it behaves like an accordion. The animation runs on the native Web Animations API through the @terrahq/side-panel package, which has no dependencies.
The package follows the WAI-ARIA tabs pattern: you write the role and aria-* attributes in the HTML and it keeps them in sync, and it takes focusable elements inside closed panels out of the tab order. The class in src/js/handler/side-panel only maps each selector to its options.
The markup
A root with data-sidepanel-root and one data-sidepanel-item per panel. Inside each item, a button with data-sidepanel-hd is the header and a data-sidepanel-bd is the body; the body needs an inner data-sidepanel-wrapper, whose width is measured so the content does not reflow while the panel animates. The item that carries aria-expanded="true" should be the one initialIndex points to.
<!-- The root holds the panels; each panel is a header (the button) and a body with an inner wrapper -->
<div class="c--side-panel-a js--side-panel" data-sidepanel-root role="tablist">
<div class="c--side-panel-a__item" data-sidepanel-item="panel-1">
<button class="c--side-panel-a__item__hd" data-sidepanel-hd
id="tab-panel-1" role="tab" aria-controls="panel-panel-1" aria-expanded="true" aria-selected="true">
<span class="c--side-panel-a__item__hd__title">Panel 1</span>
</button>
<div class="c--side-panel-a__item__bd" data-sidepanel-bd
id="panel-panel-1" role="tabpanel" aria-labelledby="tab-panel-1" aria-hidden="false">
<div class="c--side-panel-a__item__bd__wrapper" data-sidepanel-wrapper>
<p>…</p>
</div>
</div>
</div>
<div class="c--side-panel-a__item" data-sidepanel-item="panel-2">
…
</div>
</div> The handler
Handler.js has one config per selector. Everything but element is optional; the default config passes nothing else:
this.configDefault = ({ element }) => ({
element,
}); What the framework does for you
- Lazy loading. A side panel is only built when it gets within 100px of the viewport (Boostify, scroll method). The package is imported once and cached by the Manager.
- Page transitions. Every instance is destroyed before swup replaces the content, which cancels any running animation, and rebuilt on the new page.
- External controls. Any element on the page with
data-sidepanel-control="<item id>"opens that panel, wherever it lives.
Default
Responsive: side by side on desktop, stacked below 810px. The first panel starts open and the open title hides.
Nothing to configure. Above the breakpoint the panels sit in a row and the open one takes the free width; below it they stack and the open one takes its natural height. The title of the open panel is hidden to give the content the whole space, and comes back when the panel closes. The animation takes 0.6 seconds with an ease-in-out curve.
The active item gets a modifier built from its first class, here c--side-panel-a__item--is-active, so the SCSS can style the open state.
<div class="c--side-panel-a js--side-panel" data-sidepanel-root role="tablist">
<div class="c--side-panel-a__item" data-sidepanel-item="default-1">
<button class="c--side-panel-a__item__hd" data-sidepanel-hd … aria-expanded="true" aria-selected="true">
<span class="c--side-panel-a__item__hd__title">About Us</span>
</button>
<div class="c--side-panel-a__item__bd" data-sidepanel-bd … aria-hidden="false">
<div class="c--side-panel-a__item__bd__wrapper" data-sidepanel-wrapper><p>…</p></div>
</div>
</div>
<div class="c--side-panel-a__item" data-sidepanel-item="default-2">…</div>
<div class="c--side-panel-a__item" data-sidepanel-item="default-3">…</div>
</div> this.configDefault = ({ element }) => ({
element,
}); We are a creative studio focused on building digital experiences that push boundaries. Our team combines strategy, design, and technology to deliver projects that matter. From brand identities to complex web applications, we approach every challenge with curiosity and craftsmanship.
Horizontal
Always a row, whatever the screen width. Faster, with a bouncy ease, the second panel open and the titles kept visible.
direction: "horizontal"duration: 0.3ease: cubic-bezierinitialIndex: 1hideTitleOnActive: false direction: "horizontal" turns the responsive switch off: the panels stay side by side on every screen, and the package sets the vertical writing mode of the titles itself, so the SCSS media query is not needed. initialIndex: 1 opens the second panel first, and hideTitleOnActive: false keeps the title of the open panel in view next to its content.
The animation is half as long as the default and uses an overshooting cubic-bezier, so each panel settles with a small bounce. Any CSS easing string works.
<!-- Same markup, another hook. The second panel carries the open state because initialIndex is 1 -->
<div class="c--side-panel-a js--side-panel-horizontal" data-sidepanel-root role="tablist">
<div class="c--side-panel-a__item" data-sidepanel-item="horiz-1">
<button … aria-expanded="false" aria-selected="false">…</button>
<div … aria-hidden="true">…</div>
</div>
<div class="c--side-panel-a__item" data-sidepanel-item="horiz-2">
<button … aria-expanded="true" aria-selected="true">…</button>
<div … aria-hidden="false">…</div>
</div>
<div class="c--side-panel-a__item" data-sidepanel-item="horiz-3">…</div>
</div> this.configHorizontal = ({ element }) => ({
element,
direction: "horizontal",
duration: 0.3,
ease: "cubic-bezier(0.34, 1.56, 0.64, 1)",
initialIndex: 1,
hideTitleOnActive: false,
}); direction: "horizontal"duration: 0.3ease: cubic-bezierinitialIndex: 1hideTitleOnActive: false We write code that is clean, performant, and maintainable. Our front-end stack evolves with the industry, but our principles stay constant: semantic HTML, accessible interfaces, progressive enhancement, and animations that delight without distracting. Performance is a feature, not an afterthought.
Scrollable
Content taller than the panel. The body scrolls inside, the row keeps its height.
hideTitleOnActive: false The package does not limit the height of the panels: the SCSS does, with max-height: 80vh on the root and overflow-y: auto on the body. When the content is taller than that, the body scrolls on its own and the rest of the page stays where it is. The scrollbar gutter is reserved, so the text does not jump when a panel opens.
The only option here is hideTitleOnActive: false: with long content, the title is the only thing that tells which panel is open.
<div class="c--side-panel-a js--side-panel-scrollable" data-sidepanel-root role="tablist">
<div class="c--side-panel-a__item" data-sidepanel-item="scroll-1">
<button class="c--side-panel-a__item__hd" data-sidepanel-hd …>…</button>
<div class="c--side-panel-a__item__bd" data-sidepanel-bd …>
<div class="c--side-panel-a__item__bd__wrapper" data-sidepanel-wrapper>
<p>A long text, taller than the panel…</p>
</div>
</div>
</div>
…
</div> this.configScrollable = ({ element }) => ({
element,
hideTitleOnActive: false,
}); hideTitleOnActive: false Our philosophy is rooted in the belief that great digital products are born from the intersection of empathy, craft, and systems thinking. We don't start with technology — we start with people. Who will use this? What do they need? What frustrates them today? Only after we understand the human context do we begin to think about interfaces, interactions, and infrastructure. We believe that constraints breed creativity. A tight deadline, a limited budget, a tricky technical requirement — these are not obstacles, they are design inputs. The best work happens when the team is forced to prioritize ruthlessly, to strip away everything that doesn't serve the user, and to find elegant solutions to messy problems. Collaboration is not a buzzword for us. It's the operating model. Designers sit with developers. Strategists sit with both. We share work early and often, before it's polished, before it's ready. The feedback loop is tight because the cost of late changes is high and the value of early alignment is immense. We care deeply about accessibility. Not as a checklist item, not as a legal requirement, but as a fundamental design principle. If someone can't use what we build, we haven't done our job. Screen readers, keyboard navigation, color contrast, motion preferences — these are first-class concerns, not afterthoughts. Performance is another non-negotiable. A beautiful interface that takes five seconds to load is a failed interface. We measure, we optimize, we set budgets. Every kilobyte of JavaScript, every render-blocking resource, every layout shift — they all matter. Speed is a feature, and it's one that benefits every single user. Finally, we believe in shipping. Perfection is the enemy of progress. We'd rather launch something good and iterate than polish something perfect that never sees the light of day. The real world is the best testing environment, and real users are the best critics.
Options reference
Everything you can set, in the config of Handler.js, and the attributes the package reads from the HTML.
Config (Handler.js)
| Option | Default | What it does |
|---|---|---|
element | required | The root that wraps the panels. Set by the handler |
direction | "responsive" | responsive is a row above the breakpoint and a column below. horizontal is always a row, vertical always a column |
mobileBreakpoint | 810 | Viewport width in px where responsive switches from row to column |
initialIndex | 0 | Which panel starts open, counting from zero |
hideTitleOnActive | true | Hides the title of the open panel. false keeps it next to the content |
duration | 0.6 | Seconds the open/close animation takes |
ease | "cubic-bezier(0.76, 0, 0.24, 1)" | Any CSS easing string |
activeModifier | "--is-active" | Suffix added to the first class of the open item, e.g. c--side-panel-a__item--is-active |
scrollToActive | null | Vertical mode only. A number scrolls the page to the opened panel with that offset in px from the top, when its title has left the viewport |
nameSpace | "sidepanel" | Prefix of the data-* attributes, for two independent sets on one page |
onComplete | null | (instance) => {}, called once after the setup |
onChange | null | (index, item, instance) => {}, called every time the open panel changes |
Attributes (HTML)
| Attribute | Where | What it does |
|---|---|---|
data-sidepanel-root | The root | Marks the element the handler passes as element |
data-sidepanel-item="id" | Each panel | Identifies the panel. The id is what an external control targets |
data-sidepanel-hd | The button | The header. Clicking it opens the panel |
data-sidepanel-bd | The body | The content area that animates open and closed |
data-sidepanel-wrapper | Inside the body | Inner wrapper, measured so the content keeps its width while the panel animates |
data-sidepanel-control="id" | Anywhere | A button outside the component that opens the panel with that id |
The package also exposes setActive(index), activeIndex and destroy() on the instance. In Punky the options live in Handler.js: the package's data-direction and similar root attributes belong to its own demo mount and are not read here.