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.

Default

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,
});
Live example

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.

Variation 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,
});
Live example
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.

Variation 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,
});
Live example
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.

Reference

Config (Handler.js)

OptionDefaultWhat it does
elementrequiredThe 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
mobileBreakpoint810Viewport width in px where responsive switches from row to column
initialIndex0Which panel starts open, counting from zero
hideTitleOnActivetrueHides the title of the open panel. false keeps it next to the content
duration0.6Seconds 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
scrollToActivenullVertical 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
onCompletenull(instance) => {}, called once after the setup
onChangenull(index, item, instance) => {}, called every time the open panel changes

Attributes (HTML)

AttributeWhereWhat it does
data-sidepanel-rootThe rootMarks the element the handler passes as element
data-sidepanel-item="id"Each panelIdentifies the panel. The id is what an external control targets
data-sidepanel-hdThe buttonThe header. Clicking it opens the panel
data-sidepanel-bdThe bodyThe content area that animates open and closed
data-sidepanel-wrapperInside the bodyInner wrapper, measured so the content keeps its width while the panel animates
data-sidepanel-control="id"AnywhereA 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.