Drop
import { useEffect, useId, useRef, useState } from 'react';
const RANGES = [
{ label: 'Day', value: '1,204', note: 'visits today', bars: [30, 50, 42, 70, 64, 90, 76] },
{ label: 'Week', value: '8,630', note: 'visits this week', bars: [60, 84, 70, 66, 92, 48, 40] },
{ label: 'Month', value: '34.9k', note: 'visits this month', bars: [40, 52, 66, 58, 74, 88, 96] },
];
const css = `
.tabs-drop { width: 240px; }
.tabs-drop__list { display: flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.tabs-drop__tab { flex: 1; height: 26px; border: 0; border-radius: 7px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-drop__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-drop__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-drop__box { position: relative; height: 104px; border-radius: 14px; overflow: hidden; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-drop__panel { position: absolute; inset: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 14px; }
.tabs-drop__panel[hidden] { display: none; }
.tabs-drop__value { display: block; font-size: 26px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.tabs-drop__note { font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.tabs-drop__bars { display: flex; align-items: flex-end; gap: 3px; height: 56px; }
.tabs-drop__bars span { width: 7px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); }
.tabs-drop__bars span:last-child { background: light-dark(#373a40, #f1f2f3); }
/* The new panel drops in from above and bounces to rest; the old one falls out of the bottom, tipping. */
.tabs-drop__panel--in { animation: tabs-drop-in 0.75s 0.08s backwards; }
@keyframes tabs-drop-in {
from { transform: translateY(-115%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
52% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
68% { transform: translateY(-11%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
82% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
91% { transform: translateY(-3%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
to { transform: translateY(0); }
}
.tabs-drop__panel--out { animation: tabs-drop-out 0.42s cubic-bezier(0.5, 0, 0.9, 0.5) forwards; pointer-events: none; }
@keyframes tabs-drop-out {
to { transform: translateY(125%) rotate(8deg); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-drop__panel--in { animation: none; }
.tabs-drop__panel--out { display: none; }
}
`;
export function DropTabs() {
const id = useId();
const [s, setS] = useState({ sel: 0, out: -1, gen: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
const pick = (i: number) => {
if (i === s.sel) return;
setS({ sel: i, out: s.sel, gen: s.gen + 1 });
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setS((o) => ({ ...o, out: -1 })), 450);
};
const n = RANGES.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (s.sel + 1) % n, ArrowLeft: (s.sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-drop">
<div className="tabs-drop__list" role="tablist" aria-label="Range" onKeyDown={onKeyDown}>
{RANGES.map((r, i) => (
<button
key={r.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-drop__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={s.sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={s.sel === i ? 0 : -1}
onClick={() => pick(i)}
>
{r.label}
</button>
))}
</div>
<div className="tabs-drop__box">
{RANGES.map((r, i) => (
<div
key={i === s.sel || i === s.out ? `${i}-${s.gen}` : i}
className={`tabs-drop__panel${i === s.sel && s.gen ? ' tabs-drop__panel--in' : ''}${i === s.out ? ' tabs-drop__panel--out' : ''}`}
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
aria-hidden={i === s.out || undefined}
hidden={i !== s.sel && i !== s.out}
>
<div>
<span className="tabs-drop__value">{r.value}</span>
<span className="tabs-drop__note">{r.note}</span>
</div>
<div className="tabs-drop__bars" aria-hidden="true">
{r.bars.map((h, k) => (
<span key={k} style={{ height: `${h}%` }} />
))}
</div>
</div>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsDrop(el) on the mounted element instead.
<div class="tabs-drop">
<div class="tabs-drop__list" role="tablist" aria-label="Range">
<button class="tabs-drop__tab" type="button" role="tab" id="tabs-drop-tab-0" aria-selected="true" aria-controls="tabs-drop-panel-0" tabindex="0">Day</button>
<button class="tabs-drop__tab" type="button" role="tab" id="tabs-drop-tab-1" aria-selected="false" aria-controls="tabs-drop-panel-1" tabindex="-1">Week</button>
<button class="tabs-drop__tab" type="button" role="tab" id="tabs-drop-tab-2" aria-selected="false" aria-controls="tabs-drop-panel-2" tabindex="-1">Month</button>
</div>
<div class="tabs-drop__box">
<div class="tabs-drop__panel" role="tabpanel" id="tabs-drop-panel-0" aria-labelledby="tabs-drop-tab-0">
<div>
<span class="tabs-drop__value">1,204</span>
<span class="tabs-drop__note">visits today</span>
</div>
<div class="tabs-drop__bars" aria-hidden="true">
<span style="height:30%"></span>
<span style="height:50%"></span>
<span style="height:42%"></span>
<span style="height:70%"></span>
<span style="height:64%"></span>
<span style="height:90%"></span>
<span style="height:76%"></span>
</div>
</div>
<div class="tabs-drop__panel" role="tabpanel" id="tabs-drop-panel-1" aria-labelledby="tabs-drop-tab-1" hidden="">
<div>
<span class="tabs-drop__value">8,630</span>
<span class="tabs-drop__note">visits this week</span>
</div>
<div class="tabs-drop__bars" aria-hidden="true">
<span style="height:60%"></span>
<span style="height:84%"></span>
<span style="height:70%"></span>
<span style="height:66%"></span>
<span style="height:92%"></span>
<span style="height:48%"></span>
<span style="height:40%"></span>
</div>
</div>
<div class="tabs-drop__panel" role="tabpanel" id="tabs-drop-panel-2" aria-labelledby="tabs-drop-tab-2" hidden="">
<div>
<span class="tabs-drop__value">34.9k</span>
<span class="tabs-drop__note">visits this month</span>
</div>
<div class="tabs-drop__bars" aria-hidden="true">
<span style="height:40%"></span>
<span style="height:52%"></span>
<span style="height:66%"></span>
<span style="height:58%"></span>
<span style="height:74%"></span>
<span style="height:88%"></span>
<span style="height:96%"></span>
</div>
</div>
</div>
</div>.tabs-drop { width: 240px; }
.tabs-drop__list { display: flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.tabs-drop__tab { flex: 1; height: 26px; border: 0; border-radius: 7px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-drop__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-drop__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-drop__box { position: relative; height: 104px; border-radius: 14px; overflow: hidden; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-drop__panel { position: absolute; inset: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 14px; }
.tabs-drop__panel[hidden] { display: none; }
.tabs-drop__value { display: block; font-size: 26px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; }
.tabs-drop__note { font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.tabs-drop__bars { display: flex; align-items: flex-end; gap: 3px; height: 56px; }
.tabs-drop__bars span { width: 7px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); }
.tabs-drop__bars span:last-child { background: light-dark(#373a40, #f1f2f3); }
/* The new panel drops in from above and bounces to rest; the old one falls out of the bottom, tipping. */
.tabs-drop__panel--in { animation: tabs-drop-in 0.75s 0.08s backwards; }
@keyframes tabs-drop-in {
from { transform: translateY(-115%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
52% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
68% { transform: translateY(-11%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
82% { transform: translateY(0); animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1); }
91% { transform: translateY(-3%); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.5); }
to { transform: translateY(0); }
}
.tabs-drop__panel--out { animation: tabs-drop-out 0.42s cubic-bezier(0.5, 0, 0.9, 0.5) forwards; pointer-events: none; }
@keyframes tabs-drop-out {
to { transform: translateY(125%) rotate(8deg); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-drop__panel--in { animation: none; }
.tabs-drop__panel--out { display: none; }
}function initTabsDrop(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var timer = 0;
var out = -1;
function settle(replay) {
panels.forEach(function (panel, k) {
panel.classList.remove('tabs-drop__panel--in', 'tabs-drop__panel--out');
// Reading a layout value between removing and adding a class restarts its animation.
if (replay) void panel.offsetWidth;
panel.classList.toggle('tabs-drop__panel--in', k === sel);
panel.classList.toggle('tabs-drop__panel--out', k === out);
if (k === out) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
panel.hidden = k !== sel && k !== out;
});
}
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
// The old panel falls out of the bottom tipping over while the new one drops in and bounces.
out = prev;
settle(true);
clearTimeout(timer);
timer = setTimeout(function () {
out = -1;
panels[prev].classList.remove('tabs-drop__panel--out');
panels[prev].removeAttribute('aria-hidden');
panels[prev].hidden = prev !== sel;
}, 450);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
clearTimeout(timer);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-drop').forEach(initTabsDrop);Goal: Create a "Drop" tab set.
Visual behavior: Range tabs over a stats panel: the old panel falls out of the bottom tipping over, and the new one drops in from above and bounces to rest.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- On each switch the new panel replays a keyframe that drops it from above with a bounce, and the old one stays on top long enough to play a fall that tips it out of the clipped box.
- Restarting the classes on every switch replays both animations, even on quick picks.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. The falling panel is aria-hidden. Under prefers-reduced-motion the panels swap without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Drop" tab set.
Visual behavior: Range tabs over a stats panel: the old panel falls out of the bottom tipping over, and the new one drops in from above and bounces to rest.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- On each switch the new panel replays a keyframe that drops it from above with a bounce, and the old one stays on top long enough to play a fall that tips it out of the clipped box.
- Restarting the classes on every switch replays both animations, even on quick picks.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. The falling panel is aria-hidden. Under prefers-reduced-motion the panels swap without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Range tabs over a stats panel: the old panel falls out of the bottom tipping over, and the new one drops in from above and bounces to rest.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe, spring
- Techniques
- keyframes, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. The falling panel is aria-hidden. Under prefers-reduced-motion the panels swap without moving.