02Tabs that swap the same example per language
Three tabs — CSS, SCSS, Tailwind — show the same result in three syntaxes, and the bar underneath slides to the chosen tab with translateX. The tabs must keep their content width or the bar lands on the wrong label.
Best forLibrary docs that must show one feature in several syntaxes
translateXaria-selectedtablist

<main class="stage">
<div class="lts is-demo" id="root">
<div class="pane">
<div class="pane__head">
<div class="tabs" role="tablist" aria-label="Select language" id="tablist">
<button class="tab" role="tab" id="tabCss" aria-selected="true" aria-controls="panelCss" data-lang="css" type="button">CSS</button>
<button class="tab" role="tab" id="tabScss" aria-selected="false" aria-controls="panelScss" tabindex="-1" data-lang="scss" type="button">SCSS</button>
<button class="tab" role="tab" id="tabTw" aria-selected="false" aria-controls="panelTw" tabindex="-1" data-lang="tailwind" type="button">Tailwind</button>
<span class="tabs__indicator" id="indicator" aria-hidden="true"></span>
</div>
</div>
<div class="pane__body">
<pre class="panel" id="panelCss" role="tabpanel" aria-labelledby="tabCss"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
}</code></pre>
<pre class="panel" id="panelScss" role="tabpanel" aria-labelledby="tabScss" hidden><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">&:hover</span> { <span class="k">opacity</span>: <span class="n">.8</span>; }
}</code></pre>
<pre class="panel" id="panelTw" role="tabpanel" aria-labelledby="tabTw" hidden><code><span class="c"><button class="</span>
<span class="s">flex items-center gap-2</span>
<span class="s">px-4 py-2 rounded-xl</span>
<span class="c">"></span>
Copy
<span class="c"></button></span></code></pre>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
@include stage($stage-yellow);
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $subject-cream;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-2;
background: rgba(255, 247, 230, .06);
border-bottom: 1px solid rgba(255, 247, 230, .12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 $sp-3;
border: 0;
background: transparent;
color: $syn-dim;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color $duration $easing;
}
.tab:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tab[aria-selected="true"] { color: $subject-cream; }
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: $r-xs;
background: $color;
transform-origin: left center; // 규칙8: 탭 언더라인 왼쪽 기준
transform: translateX(0) scaleX(1);
transition: transform $duration $easing;
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: $sp-3;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] { display: block; opacity: 0; }
.k { color: $syn-key; }
.s { color: $syn-str; }
.n { color: $syn-num; }
.c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 인디케이터는 부드럽게 미끄러지고, 코드 본문 세 겹은 steps(1,end) 컷으로 갈아 끼운다
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss { animation-name: lts-tab-css; }
.lts.is-demo #tabScss { animation-name: lts-tab-scss; }
.lts.is-demo #tabTw { animation-name: lts-tab-tw; }
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss { animation-name: lts-panel-css; }
.lts.is-demo #panelScss { animation-name: lts-panel-scss; }
.lts.is-demo #panelTw { animation-name: lts-panel-tw; }
// 폭·오프셋은 Playwright getBoundingClientRect 실측값(스크래치패드/261_tabmeasure.py):
// CSS 43.2px @0 · SCSS 49.38px @43.2 · Tailwind 67.05px @92.58 (기준폭 = CSS 43.2px)
@keyframes lts-indicator {
0% { transform: translateX(0) scaleX(1); }
30% { transform: translateX(43.2px) scaleX(1.14); }
60% { transform: translateX(92.58px) scaleX(1.55); }
88% { transform: translateX(0) scaleX(1); }
100% { transform: translateX(0) scaleX(1); }
}
@keyframes lts-tab-css {
0% { color: $subject-cream; }
30% { color: $syn-dim; }
88% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@keyframes lts-tab-scss {
0% { color: $syn-dim; }
30% { color: $subject-cream; }
60% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-tab-tw {
0% { color: $syn-dim; }
60% { color: $subject-cream; }
88% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-panel-css {
0% { opacity: 1; }
30% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes lts-panel-scss {
0% { opacity: 0; }
30% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lts-panel-tw {
0% { opacity: 0; }
60% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; height: 118px; }
.panel { inset: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { animation: none !important; }
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.06);
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 12px;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tab[aria-selected=true] {
color: #fff7e6;
}
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: 4px;
background: #2f6df6;
transform-origin: left center;
transform: translateX(0) scaleX(1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: 12px;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] {
display: block;
opacity: 0;
}
.k {
color: #ff4d1f;
}
.s {
color: #ffd23f;
}
.n {
color: #6ec8ff;
}
.c {
color: rgba(255, 247, 230, 0.72);
}
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss {
animation-name: lts-tab-css;
}
.lts.is-demo #tabScss {
animation-name: lts-tab-scss;
}
.lts.is-demo #tabTw {
animation-name: lts-tab-tw;
}
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss {
animation-name: lts-panel-css;
}
.lts.is-demo #panelScss {
animation-name: lts-panel-scss;
}
.lts.is-demo #panelTw {
animation-name: lts-panel-tw;
}
@keyframes lts-indicator {
0% {
transform: translateX(0) scaleX(1);
}
30% {
transform: translateX(43.2px) scaleX(1.14);
}
60% {
transform: translateX(92.58px) scaleX(1.55);
}
88% {
transform: translateX(0) scaleX(1);
}
100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes lts-tab-css {
0% {
color: #fff7e6;
}
30% {
color: rgba(255, 247, 230, 0.72);
}
88% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@keyframes lts-tab-scss {
0% {
color: rgba(255, 247, 230, 0.72);
}
30% {
color: #fff7e6;
}
60% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-tab-tw {
0% {
color: rgba(255, 247, 230, 0.72);
}
60% {
color: #fff7e6;
}
88% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-panel-css {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lts-panel-scss {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes lts-panel-tw {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
height: 118px;
}
.panel {
inset: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation: none !important;
}
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
opacity: 0;
}
}var root = document.getElementById('root');
var tablist = document.getElementById('tablist');
var indicator = document.getElementById('indicator');
var tabs = [document.getElementById('tabCss'), document.getElementById('tabScss'), document.getElementById('tabTw')];
var panels = [document.getElementById('panelCss'), document.getElementById('panelScss'), document.getElementById('panelTw')];
function moveIndicator(tab) {
var tabsRect = tablist.getBoundingClientRect();
var r = tab.getBoundingClientRect();
var base = tabs[0].getBoundingClientRect().width;
var left = r.left - tabsRect.left;
var scale = r.width / base;
indicator.style.transform = 'translateX(' + left + 'px) scaleX(' + scale + ')';
}
function select(i) {
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
panels[idx].hidden = !on;
});
moveIndicator(tabs[i]);
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
root.classList.remove('is-demo');
select(i);
});
});
tablist.addEventListener('keydown', function (e) {
var i = tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
root.classList.remove('is-demo');
var next = e.key === 'ArrowRight' ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
select(next);
tabs[next].focus({ preventScroll: true });
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./LanguageTabSwitch.module.scss";
export type LanguageTabSwitchProps = {
tablistLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
type KeyEvt = { key: string; preventDefault(): void };
const LANGS = ["css", "scss", "tailwind"] as const;
type Lang = (typeof LANGS)[number];
const LABELS: Record<Lang, string> = { css: "CSS", scss: "SCSS", tailwind: "Tailwind" };
const TAB_CLASS_KEY: Record<Lang, "tabCss" | "tabScss" | "tabTw"> = { css: "tabCss", scss: "tabScss", tailwind: "tabTw" };
function CssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n}"}
</code>
);
}
function ScssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>&:hover</span> {"{ "}
<span className={styles.k}>opacity</span>: <span className={styles.n}>.8</span>; {"}"}
{"\n}"}
</code>
);
}
function TailwindCode() {
return (
<code>
<span className={styles.c}>{'<button class="'}</span>
{"\n "}
<span className={styles.s}>flex items-center gap-2</span>
{"\n "}
<span className={styles.s}>px-4 py-2 rounded-xl</span>
{"\n"}
<span className={styles.c}>{'">'}</span>
{"\n Copy\n"}
<span className={styles.c}>{"</button>"}</span>
</code>
);
}
const CODES: Record<Lang, () => ReactNode> = { css: CssCode, scss: ScssCode, tailwind: TailwindCode };
export default function LanguageTabSwitch({
tablistLabel = "Select language",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LanguageTabSwitchProps) {
const [live, setLive] = useState(demo);
const [active, setActive] = useState<Lang>("css");
const [ind, setInd] = useState<{ left: number; scale: number }>({ left: 0, scale: 1 });
const tablistRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const tabRefs = useRef<Record<Lang, HTMLButtonElement | null>>({ css: null, scss: null, tailwind: null }) as Box<
Record<Lang, HTMLButtonElement | null>
>;
function measure(lang: Lang) {
const list = tablistRef.current;
const map = tabRefs.current;
if (!list || !map) return;
const base = map.css;
const tab = map[lang];
if (!base || !tab) return;
const listRect = list.getBoundingClientRect();
const baseRect = base.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
setInd({ left: tabRect.left - listRect.left, scale: tabRect.width / baseRect.width });
}
function select(lang: Lang) {
setLive(false);
setActive(lang);
measure(lang);
}
function onKeyDown(e: KeyEvt) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = LANGS.indexOf(active);
const next = e.key === "ArrowRight" ? (i + 1) % LANGS.length : (i - 1 + LANGS.length) % LANGS.length;
select(LANGS[next]);
const btn = tabRefs.current ? tabRefs.current[LANGS[next]] : null;
if (btn) btn.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ActiveCode = CODES[active];
function setTabRef(lang: Lang, el: HTMLButtonElement | null) {
const map = tabRefs.current;
if (map) map[lang] = el;
}
return (
<div className={[styles.lts, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<div
className={styles.tabs}
role="tablist"
aria-label={tablistLabel}
ref={tablistRef}
onKeyDown={onKeyDown}
>
{LANGS.map((lang) => (
<button
key={lang}
className={[styles.tab, styles[TAB_CLASS_KEY[lang]]].join(" ")}
role="tab"
type="button"
aria-selected={live ? undefined : active === lang}
tabIndex={live ? -1 : active === lang ? 0 : -1}
ref={(el: HTMLButtonElement | null) => setTabRef(lang, el)}
onClick={() => select(lang)}
>
{LABELS[lang]}
</button>
))}
<span
className={styles.tabsIndicator}
aria-hidden="true"
style={live ? undefined : { transform: `translateX(${ind.left}px) scaleX(${ind.scale})` }}
/>
</div>
</div>
<div className={styles.paneBody}>
{live ? (
<>
<pre className={[styles.panel, styles.panelCss].join(" ")}>
<CssCode />
</pre>
<pre className={[styles.panel, styles.panelScss].join(" ")}>
<ScssCode />
</pre>
<pre className={[styles.panel, styles.panelTw].join(" ")}>
<TailwindCode />
</pre>
</>
) : (
<pre className={styles.panel}>
<ActiveCode />
</pre>
)}
</div>
</div>
</div>
);
}02 Tabs that swap the same example per language — Measured area 2.667% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · 9 Code Block UI Patterns — No Highlighter Library
The post this demo lives in
9 Code Block UI Patterns — No Highlighter LibraryA code block UI is the small frame built around a snippet — header, copy button, tabs — that tells a reader what they ar See all demos in this post
If you like this effect - similar demos
Embeds04 Social mention cards that roll in from the side9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds07 Precipitation bars that grow, and a compass that points into the windWeather Widget Design — 9 Widgets, No Library
Embeds01 YouTube responsive embed — a video frame that recomputes its ratio from the available width9 YouTube Embed Code UI Patterns (Copy-Paste)