@terrahq/side-panel
v1.0.1
Published
A lightweight JavaScript library for creating expandable side panel components with multiple visual variants
Readme
@terrahq/side-panel
A zero-dependency side panel component. Expands one panel at a time while the others collapse to show only their title. Supports horizontal, vertical, and responsive layouts with full keyboard accessibility. Animations use the native Web Animations API.
Installation
npm install @terrahq/side-panelQuick Start
HTML
Add data-sidepanel-root to the container. Each panel needs a clickable header (data-sidepanel-hd) and a content area (data-sidepanel-bd) with an inner wrapper (data-sidepanel-wrapper).
<div class="c--side-panel-a" 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>
<!-- Your content here -->
</div>
</div>
</div>
<div class="c--side-panel-a__item" data-sidepanel-item="panel-2">
<button class="c--side-panel-a__item__hd" data-sidepanel-hd
id="tab-panel-2" role="tab" aria-controls="panel-panel-2"
aria-expanded="false" aria-selected="false">
<span class="c--side-panel-a__item__hd__title">Panel 2</span>
</button>
<div class="c--side-panel-a__item__bd" data-sidepanel-bd
id="panel-panel-2" role="tabpanel" aria-labelledby="tab-panel-2"
aria-hidden="true">
<div class="c--side-panel-a__item__bd__wrapper" data-sidepanel-wrapper>
<!-- Your content here -->
</div>
</div>
</div>
</div>JavaScript
import SidePanel from '@terrahq/side-panel';
new SidePanel({
element: document.querySelector('[data-sidepanel-root]'),
});That's it! Everything else is optional.
Options
Every option has a sensible default. You only need to pass element.
element (required)
The root HTML element that wraps all your panels.
new SidePanel({
element: document.querySelector('[data-sidepanel-root]'),
});nameSpace
Default: 'sidepanel'
Changes the prefix for all data-* attributes. Useful if you have multiple independent panel systems on the same page and need them to not conflict with each other.
// Now the component looks for data-myns-item, data-myns-hd, etc.
new SidePanel({ element: el, nameSpace: 'myns' });initialIndex
Default: 0
Which panel should be open when the page first loads. 0 is the first panel, 1 the second, etc.
// Start with the third panel open
new SidePanel({ element: el, initialIndex: 2 });activeModifier
Default: '--is-active'
The CSS class suffix added to the active panel. The component reads the first class on each item and appends this modifier. For example, if your item has class c--side-panel-a__item, the active class becomes c--side-panel-a__item--is-active. Change this if your CSS uses a different naming convention.
new SidePanel({ element: el, activeModifier: '--active' });duration
Default: 0.6
How long the open/close animation takes, in seconds. Lower values feel snappy, higher values feel smooth.
// Fast animation
new SidePanel({ element: el, duration: 0.3 });ease
Default: 'cubic-bezier(0.76, 0, 0.24, 1)'
The easing curve for animations. Accepts any valid CSS easing string. The default gives a smooth ease-in-out feel.
// Linear (no acceleration)
new SidePanel({ element: el, ease: 'linear' });
// Bouncy
new SidePanel({ element: el, ease: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });mobileBreakpoint
Default: 810
The viewport width (in pixels) where the component switches from horizontal layout to vertical layout. Only matters when direction is 'responsive'. If the browser window is narrower than this value, panels stack vertically. If wider, they sit side by side.
// Switch to vertical below 1024px
new SidePanel({ element: el, mobileBreakpoint: 1024 });direction
Default: 'responsive'
Controls the layout mode:
'responsive'— horizontal on desktop, vertical on mobile (switches atmobileBreakpoint)'horizontal'— always side by side, even on small screens'vertical'— always stacked, like a classic accordion
// Always vertical, ignores screen size
new SidePanel({ element: el, direction: 'vertical' });hideTitleOnActive
Default: true
When true, the title/header of the active panel is hidden during the animation, giving more room to the content. When false, the title stays visible even when the panel is open. Set to false if your design wants the title always showing.
// Keep titles visible
new SidePanel({ element: el, hideTitleOnActive: false });scrollToActive
Default: null
Only works in vertical mode. When set to a number, the page scrolls to the newly opened panel after the animation finishes — but only if the panel title has scrolled above the viewport. The number is the offset in pixels from the top of the screen. Set to null to disable.
// Scroll to the active panel with 100px offset from top
new SidePanel({ element: el, scrollToActive: 100 });onComplete
Default: null
A callback that fires once, right after the component finishes initializing. Useful for hiding a loading state or running setup code that depends on the panel being ready.
new SidePanel({
element: el,
onComplete: (instance) => {
console.log('SidePanel is ready!', instance.activeIndex);
},
});onChange
Default: null
A callback that fires every time the active panel changes. Receives the new index, the new active DOM element, and the instance. Useful for syncing other UI, tracking analytics, or triggering secondary animations.
new SidePanel({
element: el,
onChange: (index, item, instance) => {
console.log(`Switched to panel ${index}`);
},
});Data Attributes on the Container
Instead of passing JavaScript options, you can configure behavior directly in HTML using data attributes on the data-sidepanel-root element. These are read by the mounting script.
| Attribute | What it does |
| --- | --- |
| data-hide-title="false" | Keep panel titles visible when active (default is "true", which hides them) |
| data-direction="vertical" | Force vertical layout. Use "horizontal" for always horizontal, or omit for responsive |
| data-breakpoint="1024" | Change the mobile breakpoint (default 810) |
| data-duration="0.3" | Speed up or slow down animations (default 0.6 seconds) |
| data-ease="linear" | Change the animation curve (default "cubic-bezier(0.76, 0, 0.24, 1)") |
| data-initial-index="2" | Which panel starts open (default 0, the first one) |
| data-scroll-to-active="100" | In vertical mode, scroll to the active panel with this offset from the top. Omit to disable |
Example:
<div class="c--side-panel-a"
data-sidepanel-root
data-direction="vertical"
data-hide-title="false"
data-scroll-to-active="100"
role="tablist">
...
</div>Data Attributes on Children
These go on elements inside the container. All use the nameSpace prefix (default: sidepanel).
| Attribute | Where to put it | What it does |
| --- | --- | --- |
| data-sidepanel-item="my-id" | Each panel wrapper | Identifies a panel. The "my-id" value is used by external controls to target this panel |
| data-sidepanel-hd | The clickable title/button | Marks the element as the panel header. Clicking it opens this panel |
| data-sidepanel-bd | The content container | Marks the collapsible content area. This is what gets animated open/closed |
| data-sidepanel-wrapper | A div inside bd | Inner wrapper for the content. JS calculates its width in horizontal mode so the content doesn't overflow |
| data-sidepanel-control="my-id" | Any element on the page | An external button that opens the panel with the matching ID. Can live anywhere on the page, not just inside the component |
External Controls
You can place buttons anywhere on the page that open a specific panel. Just give them data-sidepanel-control with the panel's ID:
<!-- These can be in a nav, a sidebar, anywhere -->
<button data-sidepanel-control="panel-1">Go to Panel 1</button>
<button data-sidepanel-control="panel-2">Go to Panel 2</button>Direction Modes
Responsive (default)
Horizontal above the breakpoint, vertical below it. No extra attributes needed.
<div class="c--side-panel-a" data-sidepanel-root>Always Horizontal
Panels always sit side by side, even on mobile.
<div class="c--side-panel-a" data-sidepanel-root data-direction="horizontal">Always Vertical
Panels always stack like a classic accordion.
<div class="c--side-panel-a" data-sidepanel-root data-direction="vertical">Add data-scroll-to-active="100" to automatically scroll to the active panel with a 100px offset from the top (only in vertical mode, only when the panel title is above the viewport).
API
instance.setActive(index)
Open a panel from your code. Useful for "next/prev" buttons or responding to other events.
instance.setActive(2); // Opens the third panelinstance.destroy()
Stops all animations and cleans up internal references. Call this if you're removing the component from the DOM (e.g., in a SPA on route change).
instance.destroy();instance.activeIndex
Read which panel is currently open. It's a number (0-based index).
console.log(instance.activeIndex); // 0Accessibility
The component follows the WAI-ARIA tabs pattern. You add the ARIA attributes in your HTML, and the JS keeps them in sync automatically:
aria-expandedandaria-selectedon buttons are updated when panels changearia-hiddenon content areas is toggledtabindex="-1"is added to all focusable elements inside closed panels, so keyboard users can't tab into hidden content
SCSS
@use "sass:map";
.c--side-panel-a {
width: 100%;
overflow: hidden;
@media all and ($viewport-type: $tabletm) {
display: flex;
max-height: 80vh;
}
&__item {
overflow: hidden;
border: 1px solid map.get($color-options, i);
flex: 0 0 auto;
min-width: 0;
@media all and ($viewport-type: $tabletm) {
display: flex;
}
&__hd {
display: block;
width: 100%;
text-align: left;
white-space: nowrap;
@media all and ($viewport-type: $tabletm) {
width: auto;
writing-mode: vertical-lr;
transform: rotate(180deg);
}
&__title {
display: block;
padding: $measure*2;
}
}
&__bd {
overflow: hidden;
max-height: 100%;
scrollbar-gutter: stable;
&__wrapper {
width: 100%;
padding: $measure*3 $measure*2;
}
}
}
&[data-direction="vertical"] {
display: block;
.c--side-panel-a {
&__item {
display: block;
&__hd {
width: 100%;
writing-mode: horizontal-tb;
transform: none;
}
}
}
}
}Dependencies
None. Animations use the native Web Animations API.
License
MIT
