Lamp
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Read', body: 'Pick up where you left off: chapter nine.' },
{ label: 'Listen', body: 'The audiobook is at 2 h 14 min.' },
{ label: 'Notes', body: 'Eleven highlights, three with notes.' },
];
const css = `
@keyframes tabs-lamp-swing-a {
0% { transform: rotate(calc(var(--tabs-lamp-d) * 16deg)); }
40% { transform: rotate(calc(var(--tabs-lamp-d) * -9deg)); }
65% { transform: rotate(calc(var(--tabs-lamp-d) * 5deg)); }
85% { transform: rotate(calc(var(--tabs-lamp-d) * -2deg)); }
100% { transform: rotate(0deg); }
}
@keyframes tabs-lamp-swing-b {
0% { transform: rotate(calc(var(--tabs-lamp-d) * 16deg)); }
40% { transform: rotate(calc(var(--tabs-lamp-d) * -9deg)); }
65% { transform: rotate(calc(var(--tabs-lamp-d) * 5deg)); }
85% { transform: rotate(calc(var(--tabs-lamp-d) * -2deg)); }
100% { transform: rotate(0deg); }
}
.tabs-lamp { width: 260px; }
.tabs-lamp__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding-top: 64px; border-top: 1px solid light-dark(#dbdde1, #2e3035); }
.tabs-lamp__hang { position: absolute; top: 0; left: calc((var(--tabs-lamp-i) + 0.5) / 3 * 100%); width: 0; height: 0; transition: left 0.6s cubic-bezier(0.45, 0, 0.3, 1); }
.tabs-lamp__swing { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.tabs-lamp[data-flip='a'] .tabs-lamp__swing { animation: tabs-lamp-swing-a 1.2s ease-out; }
.tabs-lamp[data-flip='b'] .tabs-lamp__swing { animation: tabs-lamp-swing-b 1.2s ease-out; }
.tabs-lamp__cord { position: absolute; left: -0.5px; top: 0; width: 1px; height: 22px; background: light-dark(#6d7179, #7d8087); }
.tabs-lamp__shade { position: absolute; left: -17px; top: 22px; width: 34px; height: 14px; border-radius: 12px 12px 2px 2px; background: light-dark(#50535b, #c6c8cd); }
.tabs-lamp__bulb { position: absolute; left: -5px; top: 33px; width: 10px; height: 6px; border-radius: 0 0 6px 6px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 60%, transparent); }
.tabs-lamp__cone { position: absolute; left: -44px; top: 36px; width: 88px; height: 64px; background: linear-gradient(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 28%, transparent), transparent); clip-path: polygon(40% 0, 60% 0, 100% 100%, 0 100%); pointer-events: none; }
.tabs-lamp__tab { position: relative; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: light-dark(#6d7179, #7d8087); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.3s 0.25s; }
.tabs-lamp__tab:hover { color: light-dark(#50535b, #c6c8cd); }
.tabs-lamp__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-lamp__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-lamp__panel { padding: 10px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-lamp__hang { transition: none; }
.tabs-lamp__swing { animation: none !important; }
}
`;
export function LampTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const [move, setMove] = useState({ d: 1, flip: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number) => {
if (i === sel) return;
setMove((m) => ({ d: i > sel ? 1 : -1, flip: m.flip + 1 }));
setSel(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const 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.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div
className="tabs-lamp"
data-flip={move.flip ? (move.flip % 2 ? 'a' : 'b') : undefined}
style={{ '--tabs-lamp-i': sel, '--tabs-lamp-d': move.d } as React.CSSProperties}
>
<div className="tabs-lamp__list" role="tablist" aria-label="Book" onKeyDown={onKeyDown}>
<span className="tabs-lamp__hang" aria-hidden="true">
<span className="tabs-lamp__swing">
<span className="tabs-lamp__cone" />
<span className="tabs-lamp__cord" />
<span className="tabs-lamp__shade" />
<span className="tabs-lamp__bulb" />
</span>
</span>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-lamp__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-lamp__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsLamp(el) on the mounted element instead.
<div class="tabs-lamp" style="--tabs-lamp-i:1;--tabs-lamp-d:1">
<div class="tabs-lamp__list" role="tablist" aria-label="Book">
<span class="tabs-lamp__hang" aria-hidden="true">
<span class="tabs-lamp__swing">
<span class="tabs-lamp__cone"></span>
<span class="tabs-lamp__cord"></span>
<span class="tabs-lamp__shade"></span>
<span class="tabs-lamp__bulb"></span>
</span>
</span>
<button class="tabs-lamp__tab" type="button" role="tab" id="tabs-lamp-tab-0" aria-selected="false" aria-controls="tabs-lamp-panel-0" tabindex="-1">Read</button>
<button class="tabs-lamp__tab" type="button" role="tab" id="tabs-lamp-tab-1" aria-selected="true" aria-controls="tabs-lamp-panel-1" tabindex="0">Listen</button>
<button class="tabs-lamp__tab" type="button" role="tab" id="tabs-lamp-tab-2" aria-selected="false" aria-controls="tabs-lamp-panel-2" tabindex="-1">Notes</button>
</div>
<div class="tabs-lamp__panel" role="tabpanel" id="tabs-lamp-panel-0" aria-labelledby="tabs-lamp-tab-0" hidden="">Pick up where you left off: chapter nine.</div>
<div class="tabs-lamp__panel" role="tabpanel" id="tabs-lamp-panel-1" aria-labelledby="tabs-lamp-tab-1">The audiobook is at 2 h 14 min.</div>
<div class="tabs-lamp__panel" role="tabpanel" id="tabs-lamp-panel-2" aria-labelledby="tabs-lamp-tab-2" hidden="">Eleven highlights, three with notes.</div>
</div>@keyframes tabs-lamp-swing-a {
0% { transform: rotate(calc(var(--tabs-lamp-d) * 16deg)); }
40% { transform: rotate(calc(var(--tabs-lamp-d) * -9deg)); }
65% { transform: rotate(calc(var(--tabs-lamp-d) * 5deg)); }
85% { transform: rotate(calc(var(--tabs-lamp-d) * -2deg)); }
100% { transform: rotate(0deg); }
}
@keyframes tabs-lamp-swing-b {
0% { transform: rotate(calc(var(--tabs-lamp-d) * 16deg)); }
40% { transform: rotate(calc(var(--tabs-lamp-d) * -9deg)); }
65% { transform: rotate(calc(var(--tabs-lamp-d) * 5deg)); }
85% { transform: rotate(calc(var(--tabs-lamp-d) * -2deg)); }
100% { transform: rotate(0deg); }
}
.tabs-lamp { width: 260px; }
.tabs-lamp__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding-top: 64px; border-top: 1px solid light-dark(#dbdde1, #2e3035); }
.tabs-lamp__hang { position: absolute; top: 0; left: calc((var(--tabs-lamp-i) + 0.5) / 3 * 100%); width: 0; height: 0; transition: left 0.6s cubic-bezier(0.45, 0, 0.3, 1); }
.tabs-lamp__swing { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.tabs-lamp[data-flip='a'] .tabs-lamp__swing { animation: tabs-lamp-swing-a 1.2s ease-out; }
.tabs-lamp[data-flip='b'] .tabs-lamp__swing { animation: tabs-lamp-swing-b 1.2s ease-out; }
.tabs-lamp__cord { position: absolute; left: -0.5px; top: 0; width: 1px; height: 22px; background: light-dark(#6d7179, #7d8087); }
.tabs-lamp__shade { position: absolute; left: -17px; top: 22px; width: 34px; height: 14px; border-radius: 12px 12px 2px 2px; background: light-dark(#50535b, #c6c8cd); }
.tabs-lamp__bulb { position: absolute; left: -5px; top: 33px; width: 10px; height: 6px; border-radius: 0 0 6px 6px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 60%, transparent); }
.tabs-lamp__cone { position: absolute; left: -44px; top: 36px; width: 88px; height: 64px; background: linear-gradient(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 28%, transparent), transparent); clip-path: polygon(40% 0, 60% 0, 100% 100%, 0 100%); pointer-events: none; }
.tabs-lamp__tab { position: relative; padding: 8px 0; border: 0; border-radius: 6px; background: none; color: light-dark(#6d7179, #7d8087); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.3s 0.25s; }
.tabs-lamp__tab:hover { color: light-dark(#50535b, #c6c8cd); }
.tabs-lamp__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-lamp__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-lamp__panel { padding: 10px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-lamp__hang { transition: none; }
.tabs-lamp__swing { animation: none !important; }
}function initTabsLamp(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var flip = 0;
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;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
// Each change flips data-flip between a and b, so the keyframes restart every time.
flip += 1;
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
// The lamp swings towards the tab it moves to.
root.style.setProperty('--tabs-lamp-i', i);
root.style.setProperty('--tabs-lamp-d', i > prev ? 1 : -1);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
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 () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-lamp').forEach(initTabsLamp);Goal: Create a "Lamp" tab set.
Visual behavior: Reading tabs under a hanging lamp: the lamp swings over to the tab you pick, sways and settles, lighting it with a cone of light.
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.
- The lamp hangs from a point positioned from the selected index; a swing keyframe tilts it the way it travelled (a direction property, 1 or -1) and lets it sway to rest.
- The light is a clip-path cone of a soft gradient.
- Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
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. Under prefers-reduced-motion the lamp moves without swinging.
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 "Lamp" tab set.
Visual behavior: Reading tabs under a hanging lamp: the lamp swings over to the tab you pick, sways and settles, lighting it with a cone of light.
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.
- The lamp hangs from a point positioned from the selected index; a swing keyframe tilts it the way it travelled (a direction property, 1 or -1) and lets it sway to rest.
- The light is a clip-path cone of a soft gradient.
- Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
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. Under prefers-reduced-motion the lamp moves without swinging.
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
Reading tabs under a hanging lamp: the lamp swings over to the tab you pick, sways and settles, lighting it with a cone of light.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, glow
- Techniques
- css variables, keyframes, clip path
- 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. Under prefers-reduced-motion the lamp moves without swinging.