Develop
import { useId, useRef, useState } from 'react';
const ITEMS = [
{ label: 'Coast', caption: 'Cliffs, Sunday', scene: 'coast' },
{ label: 'Hills', caption: 'Morning walk', scene: 'hills' },
{ label: 'City', caption: 'Last night out', scene: 'city' },
];
const css = `
/* Developing: from a pale blank, the image surfaces dark and soft, then clears to full contrast. */
@keyframes tabs-develop-develop { 0% { opacity: 0; filter: blur(3px) contrast(0.4); } 35% { opacity: 0.55; filter: blur(2px) contrast(0.6) brightness(0.6); } 100% { opacity: 1; filter: none; } }
.tabs-develop { display: grid; justify-items: center; gap: 12px; width: 240px; }
.tabs-develop__list { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-develop__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-develop__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
.tabs-develop__tab { padding: 6px 14px; border-radius: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; transition: background-color 0.2s, color 0.2s; }
.tabs-develop__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
/* An instant photo has a pale frame in either theme, so the print keeps the dark palette. */
.tabs-develop__panel { color-scheme: dark; width: 136px; padding: 8px 8px 26px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); transform: rotate(-2deg); }
.tabs-develop__panel:nth-child(even) { transform: rotate(2deg); }
.tabs-develop__photo { position: relative; height: 110px; overflow: hidden; background: light-dark(#dbdde1, #2e3035); }
.tabs-develop__img { position: absolute; inset: 0; }
.tabs-develop[data-armed] .tabs-develop__img { animation: tabs-develop-develop 2.2s ease-out both; }
.tabs-develop__img[data-scene='coast'] { background: radial-gradient(circle at 70% 26%, light-dark(#373a40, #f1f2f3) 0 9px, transparent 10px), linear-gradient(170deg, transparent 58%, light-dark(#f3f4f7, #1a1c1f) 58%), linear-gradient(light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41) 55%, light-dark(#ebedf0, #232529) 56%); }
.tabs-develop__img[data-scene='hills'] { background: radial-gradient(ellipse 80% 50% at 20% 100%, light-dark(#f3f4f7, #1a1c1f) 60%, transparent 61%), radial-gradient(ellipse 90% 60% at 85% 100%, light-dark(#ebedf0, #232529) 60%, transparent 61%), linear-gradient(light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41)); }
.tabs-develop__img[data-scene='city'] { background: linear-gradient(transparent 50%, light-dark(#fbfcfd, #121417) 50%) 0 100% / 100% 100%, repeating-linear-gradient(90deg, light-dark(#f3f4f7, #1a1c1f) 0 14px, transparent 14px 18px, light-dark(#ebedf0, #232529) 18px 30px, transparent 30px 34px) 0 100% / 100% 70% no-repeat, radial-gradient(circle at 26% 22%, light-dark(#42454c, #e5e6e9) 0 5px, transparent 6px), linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#6d7179, #7d8087)); }
.tabs-develop__caption { position: absolute; left: 10px; right: 10px; bottom: 6px; color: light-dark(#dbdde1, #2e3035); font-family: Georgia, 'Times New Roman', serif; font-size: 12px; font-style: italic; text-align: center; }
.tabs-develop__panel { position: relative; }
@media (prefers-reduced-motion: reduce) {
.tabs-develop__img { animation: none !important; }
}
`;
export function DevelopTabs() {
const id = useId();
const [state, setState] = useState({ sel: 0, prev: -1, flip: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const { sel, prev, flip } = state;
const select = (i: number) => {
if (i !== sel) setState((s) => ({ sel: i, prev: s.sel, flip: s.flip + 1 }));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = ITEMS.length;
const next = ({ ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 } as Record<string, number>)[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div
className="tabs-develop"
data-armed={flip ? '' : undefined}
data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}
style={{ '--i': sel, '--p': prev < 0 ? sel : prev } as React.CSSProperties}
>
<div className="tabs-develop__list" role="tablist" aria-label="Trip" onKeyDown={onKeyDown}>
{ITEMS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-develop__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>
{ITEMS.map((t, i) => (
<div key={t.label} className="tabs-develop__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<div className="tabs-develop__photo">
<span className="tabs-develop__img" data-scene={t.scene} role="img" aria-label={`Photo: ${t.caption}`} />
</div>
<span className="tabs-develop__caption">{t.caption}</span>
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsDevelop(el) on the mounted element instead.
<div class="tabs-develop" style="--i:0;--p:0">
<div class="tabs-develop__list" role="tablist" aria-label="Trip">
<button class="tabs-develop__tab" type="button" role="tab" id="tabs-develop-tab-0" aria-selected="true" aria-controls="tabs-develop-panel-0" tabindex="0">Coast</button>
<button class="tabs-develop__tab" type="button" role="tab" id="tabs-develop-tab-1" aria-selected="false" aria-controls="tabs-develop-panel-1" tabindex="-1">Hills</button>
<button class="tabs-develop__tab" type="button" role="tab" id="tabs-develop-tab-2" aria-selected="false" aria-controls="tabs-develop-panel-2" tabindex="-1">City</button>
</div>
<div class="tabs-develop__panel" role="tabpanel" id="tabs-develop-panel-0" aria-labelledby="tabs-develop-tab-0">
<div class="tabs-develop__photo">
<span class="tabs-develop__img" data-scene="coast" role="img" aria-label="Photo: Cliffs, Sunday"></span>
</div>
<span class="tabs-develop__caption">Cliffs, Sunday</span>
</div>
<div class="tabs-develop__panel" role="tabpanel" id="tabs-develop-panel-1" aria-labelledby="tabs-develop-tab-1" hidden="">
<div class="tabs-develop__photo">
<span class="tabs-develop__img" data-scene="hills" role="img" aria-label="Photo: Morning walk"></span>
</div>
<span class="tabs-develop__caption">Morning walk</span>
</div>
<div class="tabs-develop__panel" role="tabpanel" id="tabs-develop-panel-2" aria-labelledby="tabs-develop-tab-2" hidden="">
<div class="tabs-develop__photo">
<span class="tabs-develop__img" data-scene="city" role="img" aria-label="Photo: Last night out"></span>
</div>
<span class="tabs-develop__caption">Last night out</span>
</div>
</div>/* Developing: from a pale blank, the image surfaces dark and soft, then clears to full contrast. */
@keyframes tabs-develop-develop { 0% { opacity: 0; filter: blur(3px) contrast(0.4); } 35% { opacity: 0.55; filter: blur(2px) contrast(0.6) brightness(0.6); } 100% { opacity: 1; filter: none; } }
.tabs-develop { display: grid; justify-items: center; gap: 12px; width: 240px; }
.tabs-develop__list { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-develop__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-develop__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
.tabs-develop__tab { padding: 6px 14px; border-radius: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; transition: background-color 0.2s, color 0.2s; }
.tabs-develop__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
/* An instant photo has a pale frame in either theme, so the print keeps the dark palette. */
.tabs-develop__panel { color-scheme: dark; width: 136px; padding: 8px 8px 26px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); transform: rotate(-2deg); }
.tabs-develop__panel:nth-child(even) { transform: rotate(2deg); }
.tabs-develop__photo { position: relative; height: 110px; overflow: hidden; background: light-dark(#dbdde1, #2e3035); }
.tabs-develop__img { position: absolute; inset: 0; }
.tabs-develop[data-armed] .tabs-develop__img { animation: tabs-develop-develop 2.2s ease-out both; }
.tabs-develop__img[data-scene='coast'] { background: radial-gradient(circle at 70% 26%, light-dark(#373a40, #f1f2f3) 0 9px, transparent 10px), linear-gradient(170deg, transparent 58%, light-dark(#f3f4f7, #1a1c1f) 58%), linear-gradient(light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41) 55%, light-dark(#ebedf0, #232529) 56%); }
.tabs-develop__img[data-scene='hills'] { background: radial-gradient(ellipse 80% 50% at 20% 100%, light-dark(#f3f4f7, #1a1c1f) 60%, transparent 61%), radial-gradient(ellipse 90% 60% at 85% 100%, light-dark(#ebedf0, #232529) 60%, transparent 61%), linear-gradient(light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41)); }
.tabs-develop__img[data-scene='city'] { background: linear-gradient(transparent 50%, light-dark(#fbfcfd, #121417) 50%) 0 100% / 100% 100%, repeating-linear-gradient(90deg, light-dark(#f3f4f7, #1a1c1f) 0 14px, transparent 14px 18px, light-dark(#ebedf0, #232529) 18px 30px, transparent 30px 34px) 0 100% / 100% 70% no-repeat, radial-gradient(circle at 26% 22%, light-dark(#42454c, #e5e6e9) 0 5px, transparent 6px), linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#6d7179, #7d8087)); }
.tabs-develop__caption { position: absolute; left: 10px; right: 10px; bottom: 6px; color: light-dark(#dbdde1, #2e3035); font-family: Georgia, 'Times New Roman', serif; font-size: 12px; font-style: italic; text-align: center; }
.tabs-develop__panel { position: relative; }
@media (prefers-reduced-motion: reduce) {
.tabs-develop__img { animation: none !important; }
}function initTabsDevelop(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;
// Armed on the first change, so nothing animates on load; each change flips data-flip
// between a and b, so the keyframes restart every time.
flip += 1;
root.setAttribute('data-armed', '');
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
root.style.setProperty('--i', i);
root.style.setProperty('--p', prev);
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; });
}
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-develop').forEach(initTabsDevelop);Goal: Create a "Develop" tab set.
Visual behavior: Trip tabs as instant photos: pick a place and the new print develops in its frame, surfacing dark and soft from a pale blank and clearing to full contrast.
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.
- Each print is a gradient scene with role="img" and an aria-label; it develops with one keyframe that starts transparent, blurred and low contrast, darkens and then clears.
- The frame pins color-scheme: dark, so an instant photo keeps its pale border in either theme.
- A data-armed attribute is set on the first change, so nothing plays on page load; a panel that is shown again restarts its entrance because it comes back from display: none.
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 photo appears without developing.
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 "Develop" tab set.
Visual behavior: Trip tabs as instant photos: pick a place and the new print develops in its frame, surfacing dark and soft from a pale blank and clearing to full contrast.
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.
- Each print is a gradient scene with role="img" and an aria-label; it develops with one keyframe that starts transparent, blurred and low contrast, darkens and then clears.
- The frame pins color-scheme: dark, so an instant photo keeps its pale border in either theme.
- A data-armed attribute is set on the first change, so nothing plays on page load; a panel that is shown again restarts its entrance because it comes back from display: none.
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 photo appears without developing.
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
Trip tabs as instant photos: pick a place and the new print develops in its frame, surfacing dark and soft from a pale blank and clearing to full contrast.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change
- Techniques
- keyframes, filter, gradient
- 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 photo appears without developing.