02같은 예제를 언어별로 바꾸는 탭
CSS·SCSS·Tailwind 세 탭이 같은 결과를 세 가지 문법으로 보여 주고, 밑에 깔린 막대가 translateX 로 고른 탭까지 미끄러집니다. 탭은 내용 폭 그대로 두어야 막대가 엉뚱한 라벨에 걸리지 않습니다.
쓰임같은 기능을 여러 문법으로 안내해야 하는 라이브러리 문서
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="언어 선택" 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 같은 예제를 언어별로 바꾸는 탭 — 측정 누적면적 2.667% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창
이 데모가 속한 글
코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창코드 블록은 문서 안에 프로그래밍 언어를 그대로 옮겨 담은 상자이고, 아홉 가지 모두 복사·전환·펼침·타이핑 같은 동작이 실제로 붙어 있는 화면입니다. 이 글의 데모 전부 보기


