05길면 접었다가 펴는 코드 창
긴 파일은 grid-template-rows 를 0fr 에서 1fr 로 옮겨 여닫으므로 높이를 미리 재서 적어 둘 필요가 없습니다. 접힌 동안 아래쪽이 흐려지는 띠가 깔려 더 남았다는 사실이 눈으로 보입니다.
쓰임전체 설정 파일을 싣되 화면은 짧게 유지해야 하는 문서
grid-template-rowsaria-expandedlinear-gradient

<main class="stage">
<div class="ecb is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__file">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2"/><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25"/></g></svg>
config.js
</span>
</div>
<div class="pane__body">
<pre class="ecb__peek" id="peek"><code><span class="k">export</span> <span class="k">const</span> config = {
theme: <span class="s">"auto"</span>,
duration: <span class="n">220</span>,
easing: <span class="s">"ease-out"</span>,<span class="ecb__more" id="more"><span class="ecb__moreInner"><span class="ecb__line"> radius: <span class="n">12</span>,</span><span class="ecb__line"> shadow: <span class="k">true</span>,</span><span class="ecb__line"> locale: <span class="s">"en-US"</span>,</span><span class="ecb__line"> retry: <span class="n">3</span>,</span><span class="ecb__line">};</span></span></span></code></pre>
<span class="ecb__fade" aria-hidden="true"></span>
</div>
<div class="ecb__toggleRow">
<button class="ecb__toggle" id="toggleBtn" type="button" aria-expanded="false" aria-controls="more">
<span class="ecb__labelWrap">
<span class="ecb__label ecb__label--more" id="labelMore">더 보기</span>
<span class="ecb__label ecb__label--less" id="labelLess" aria-hidden="true">접기</span>
</span>
<svg class="ic ecb__caret" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</div>
</div>
<span class="ecb__cursor" id="cursor" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-ink);
.ecb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
padding: 0 $sp-3;
background: rgba(23, 20, 26, .06);
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.pane__file {
display: flex;
align-items: center;
gap: $sp-1;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba($subject-ink, .72);
}
.pane__file .ic { flex: none; }
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
}
.ecb__peek {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
white-space: pre;
}
.ecb__peek code { font: inherit; }
.k { color: $subject-orange-deep; }
.s { color: $subject-blue-deep; }
.n { color: $error; }
// 접힘/펼침 — grid-template-rows 0fr → 1fr, 고정 높이 없이 실제 콘텐츠 높이로 보간
.ecb__more {
display: grid;
grid-template-rows: 0fr;
}
// 실제 상호작용(비-데모)일 때만 transition — is-demo 루프는 keyframe 애니메이션만 쓴다
// (같은 속성에 transition 과 animation 을 동시에 걸면 강제 currentTime 재생 시 충돌해
// 접힘이 여러 프레임에 걸쳐 보간되지 않고 한 프레임에 훅 꺼지는 문제가 있었다)
.ecb:not(.is-demo) .ecb__more {
transition: grid-template-rows $duration $easing;
}
.ecb.is-open .ecb__more { grid-template-rows: 1fr; }
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line { display: block; }
// 바닥 페이드 띠 — 접힌 상태에서만 보인다
.ecb__fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, rgba(255, 247, 230, 0), $subject-cream);
pointer-events: none;
}
.ecb.is-open .ecb__fade { opacity: 0; }
.ecb__toggleRow {
box-sizing: border-box;
padding: 0 $sp-3 $sp-3;
}
.ecb__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: inherit;
cursor: pointer;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
transition: transform $dur-quick $easing, background $dur-quick $easing;
}
.ecb__toggle:hover { background: rgba(23, 20, 26, .14); }
.ecb__toggle:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 라벨 두 장을 겹쳐 놓고 opacity 만 바꾼다(레이아웃이 안 흔들린다) — pane__copy 의 copy__state 방식과 동일
.ecb__labelWrap {
position: relative;
display: inline-block;
width: 40px;
height: 14px;
}
.ecb__label {
position: absolute;
inset: 0;
white-space: nowrap;
transition: opacity $dur-quick $easing;
}
.ecb__label--more { opacity: 1; }
.ecb__label--less { opacity: 0; }
.ecb.is-open .ecb__label--more { opacity: 0; }
.ecb.is-open .ecb__label--less { opacity: 1; }
.ecb__caret {
flex: none;
transition: transform $dur-quick $easing;
}
.ecb.is-open .ecb__caret { transform: rotate(180deg); }
.ecb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $subject-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(252px, 16px);
}
.ecb.is-demo .ecb__cursor { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 함정1 대책: 46-62%(펼침 마무리, 다섯 줄 등장)에서 면적 변화가 가장 크게 남도록,
// 64% 재접힘 직전(62%)에 여분 줄 텍스트를 opacity 0 으로 먼저 비워 둔다.
// 이후 grid-template-rows 1fr→0fr 재접힘은 "이미 빈 영역"을 접는 것이라 픽셀 변화가 작다.
// 접힘 구간도 0.16 (32%, 0.64s) 로 펼침(0.28, 0.56s)보다 넓게 잡아 프레임당 변화율을 낮춘다.
.ecb.is-demo .ecb__more {
animation-name: ecb-grid;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__fade {
animation-name: ecb-fade;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__caret {
animation-name: ecb-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__toggle {
animation-name: ecb-press;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more { animation-name: ecb-label-more; }
.ecb.is-demo .ecb__label--less { animation-name: ecb-label-less; }
.ecb.is-demo .ecb__moreInner .ecb__line {
opacity: 0;
animation-name: ecb-line;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@for $i from 1 through 5 {
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(#{$i}) {
animation-delay: ($i - 1) * 0.015s;
}
}
.ecb.is-demo .ecb__cursor {
animation-name: ecb-cursor;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// fr 값 하나로만 62%→86% 를 맡기면 크롬이 변화를 앞쪽 몇 프레임에 몰아
// 한 프레임에 훅 접히는 것처럼 보인다(실측). 촘촘한 중간 정지점을 직접 박아
// 프레임마다 변화폭을 고르게 나눈다 — 펼침 쪽도 대칭으로 맞춘다.
// (poster 수정: 하이라이트 막을 없애고, 줄은 접힘이 시작되는 86%까지 완전 불투명하게
// 유지한다 — 62~86% 사이 "펼쳐진 빈 상자"가 반투명 유령으로 보이던 문제 대책)
@keyframes ecb-grid {
0%, 14% { grid-template-rows: 0fr; }
20% { grid-template-rows: .12fr; }
26% { grid-template-rows: .32fr; }
32% { grid-template-rows: .55fr; }
38% { grid-template-rows: .8fr; }
44%, 62% { grid-template-rows: 1fr; }
68% { grid-template-rows: .8fr; }
74% { grid-template-rows: .55fr; }
80% { grid-template-rows: .32fr; }
83% { grid-template-rows: .16fr; }
86%, 100% { grid-template-rows: 0fr; }
}
@keyframes ecb-fade {
0%, 14% { opacity: 1; }
30% { opacity: 0; }
88% { opacity: 0; }
96%, 100% { opacity: 1; }
}
// 카레트·라벨은 접힘이 거의 끝나는 84% 에 함께 되돌아온다.
// 63% 에 되돌리면 아직 펼쳐진 창에 「더 보기」가 적혀 화면이 거짓말을 한다 (poster 육안 실측)
@keyframes ecb-caret {
0%, 14% { transform: rotate(0deg); }
40%, 83% { transform: rotate(180deg); }
84%, 100% { transform: rotate(0deg); }
}
@keyframes ecb-press {
0%, 12% { transform: scale(1); }
14% { transform: scale(.94); }
18%, 100% { transform: scale(1); }
}
@keyframes ecb-label-more {
0%, 15% { opacity: 1; }
16%, 83% { opacity: 0; }
84%, 100% { opacity: 1; }
}
@keyframes ecb-label-less {
0%, 15% { opacity: 0; }
16%, 83% { opacity: 1; }
84%, 100% { opacity: 0; }
}
// 다섯 줄 등장 — translateY(6px) → 0, 46~60% 안에 끝내고 접힘이 시작되는 86%까지
// 완전 불투명을 유지한다(62~86% 사이 반투명 유령 방지). 사라짐은 접힘과 함께.
@keyframes ecb-line {
0%, 44% { opacity: 0; transform: translateY(6px); }
60%, 86% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
// 커서는 86% 안에 처음 자리로 돌아와 그 뒤로는 가만히 있는다(텔레포트 금지).
@keyframes ecb-cursor {
0%, 2% { transform: translate(252px, 16px); }
12%, 74% { transform: translate(48px, 138px); }
86%, 100% { transform: translate(252px, 16px); }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.ecb__peek { font-size: 10px; line-height: 16px; }
.pane__body { padding: $sp-2; }
.ecb__toggleRow { padding: 0 $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ecb.is-demo .ecb__more,
.ecb.is-demo .ecb__fade,
.ecb.is-demo .ecb__caret,
.ecb.is-demo .ecb__toggle,
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less,
.ecb.is-demo .ecb__moreInner .ecb__line,
.ecb.is-demo .ecb__cursor { animation: none !important; }
.ecb.is-demo .ecb__more { grid-template-rows: 0fr; }
.ecb.is-demo .ecb__cursor { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ecb {
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: #fff7e6;
color: #17141a;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
background: rgba(23, 20, 26, 0.06);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.pane__file {
display: flex;
align-items: center;
gap: 4px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.pane__file .ic {
flex: none;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
}
.ecb__peek {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
white-space: pre;
}
.ecb__peek code {
font: inherit;
}
.k {
color: #c93000;
}
.s {
color: #0a4fe8;
}
.n {
color: #c2361a;
}
.ecb__more {
display: grid;
grid-template-rows: 0fr;
}
.ecb:not(.is-demo) .ecb__more {
transition: grid-template-rows 600ms ease-out;
}
.ecb.is-open .ecb__more {
grid-template-rows: 1fr;
}
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line {
display: block;
}
.ecb__fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, rgba(255, 247, 230, 0), #fff7e6);
pointer-events: none;
}
.ecb.is-open .ecb__fade {
opacity: 0;
}
.ecb__toggleRow {
box-sizing: border-box;
padding: 0 12px 12px;
}
.ecb__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: inherit;
cursor: pointer;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
transition: transform 220ms ease-out, background 220ms ease-out;
}
.ecb__toggle:hover {
background: rgba(23, 20, 26, 0.14);
}
.ecb__toggle:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.ecb__labelWrap {
position: relative;
display: inline-block;
width: 40px;
height: 14px;
}
.ecb__label {
position: absolute;
inset: 0;
white-space: nowrap;
transition: opacity 220ms ease-out;
}
.ecb__label--more {
opacity: 1;
}
.ecb__label--less {
opacity: 0;
}
.ecb.is-open .ecb__label--more {
opacity: 0;
}
.ecb.is-open .ecb__label--less {
opacity: 1;
}
.ecb__caret {
flex: none;
transition: transform 220ms ease-out;
}
.ecb.is-open .ecb__caret {
transform: rotate(180deg);
}
.ecb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(252px, 16px);
}
.ecb.is-demo .ecb__cursor {
opacity: 1;
}
.ecb.is-demo .ecb__more {
animation-name: ecb-grid;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__fade {
animation-name: ecb-fade;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__caret {
animation-name: ecb-caret;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__toggle {
animation-name: ecb-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more {
animation-name: ecb-label-more;
}
.ecb.is-demo .ecb__label--less {
animation-name: ecb-label-less;
}
.ecb.is-demo .ecb__moreInner .ecb__line {
opacity: 0;
animation-name: ecb-line;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(1) {
animation-delay: 0s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(2) {
animation-delay: 0.015s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(3) {
animation-delay: 0.03s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(4) {
animation-delay: 0.045s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(5) {
animation-delay: 0.06s;
}
.ecb.is-demo .ecb__cursor {
animation-name: ecb-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ecb-grid {
0%, 14% {
grid-template-rows: 0fr;
}
20% {
grid-template-rows: 0.12fr;
}
26% {
grid-template-rows: 0.32fr;
}
32% {
grid-template-rows: 0.55fr;
}
38% {
grid-template-rows: 0.8fr;
}
44%, 62% {
grid-template-rows: 1fr;
}
68% {
grid-template-rows: 0.8fr;
}
74% {
grid-template-rows: 0.55fr;
}
80% {
grid-template-rows: 0.32fr;
}
83% {
grid-template-rows: 0.16fr;
}
86%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ecb-fade {
0%, 14% {
opacity: 1;
}
30% {
opacity: 0;
}
88% {
opacity: 0;
}
96%, 100% {
opacity: 1;
}
}
@keyframes ecb-caret {
0%, 14% {
transform: rotate(0deg);
}
40%, 83% {
transform: rotate(180deg);
}
84%, 100% {
transform: rotate(0deg);
}
}
@keyframes ecb-press {
0%, 12% {
transform: scale(1);
}
14% {
transform: scale(0.94);
}
18%, 100% {
transform: scale(1);
}
}
@keyframes ecb-label-more {
0%, 15% {
opacity: 1;
}
16%, 83% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes ecb-label-less {
0%, 15% {
opacity: 0;
}
16%, 83% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes ecb-line {
0%, 44% {
opacity: 0;
transform: translateY(6px);
}
60%, 86% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@keyframes ecb-cursor {
0%, 2% {
transform: translate(252px, 16px);
}
12%, 74% {
transform: translate(48px, 138px);
}
86%, 100% {
transform: translate(252px, 16px);
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.ecb__peek {
font-size: 10px;
line-height: 16px;
}
.pane__body {
padding: 8px;
}
.ecb__toggleRow {
padding: 0 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ecb.is-demo .ecb__more,
.ecb.is-demo .ecb__fade,
.ecb.is-demo .ecb__caret,
.ecb.is-demo .ecb__toggle,
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less,
.ecb.is-demo .ecb__moreInner .ecb__line,
.ecb.is-demo .ecb__cursor {
animation: none !important;
}
.ecb.is-demo .ecb__more {
grid-template-rows: 0fr;
}
.ecb.is-demo .ecb__cursor {
opacity: 0;
}
}var root = document.getElementById('root');
var btn = document.getElementById('toggleBtn');
var labelMore = document.getElementById('labelMore');
var labelLess = document.getElementById('labelLess');
function setState(open) {
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
labelMore.setAttribute('aria-hidden', open ? 'true' : 'false');
labelLess.setAttribute('aria-hidden', open ? 'false' : 'true');
root.classList.toggle('is-open', open);
}
btn.addEventListener('click', function () {
// button 요소는 Enter/Space 를 이미 click 으로 보내므로 별도 keydown 처리가 필요 없다
root.classList.remove('is-demo');
setState(btn.getAttribute('aria-expanded') !== 'true');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ExpandCollapsedBlock.module.scss";
export type ExpandCollapsedBlockProps = {
fileLabel?: string;
moreLabel?: string;
lessLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CodeIcon = () => (
<svg className={styles.ic} viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2" />
<path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25" />
</g>
</svg>
);
const CaretIcon = () => (
<svg className={[styles.ic, styles.caret].join(" ")} viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
export default function ExpandCollapsedBlock({
fileLabel = "config.js",
moreLabel = "Show more",
lessLabel = "Collapse",
demo = true,
duration = 600,
easing = "ease-out",
color = "#ff4d1f",
}: ExpandCollapsedBlockProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
function onToggle() {
setLive(false);
setOpen((v) => !v);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.ecb, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneFile}>
<CodeIcon />
{fileLabel}
</span>
</div>
<div className={styles.paneBody}>
<pre className={styles.peek}>
<code>
<span className={styles.k}>export</span> <span className={styles.k}>const</span> config = {"{"}
{"\n "}theme: <span className={styles.s}>"auto"</span>,
{"\n "}duration: <span className={styles.n}>220</span>,
{"\n "}easing: <span className={styles.s}>"ease-out"</span>,
<span className={styles.more} id="ecb-react-more">
<span className={styles.moreInner}>
<span className={styles.line}>
{" "}radius: <span className={styles.n}>12</span>,
</span>
<span className={styles.line}>
{" "}shadow: <span className={styles.k}>true</span>,
</span>
<span className={styles.line}>
{" "}locale: <span className={styles.s}>"en-US"</span>,
</span>
<span className={styles.line}>
{" "}retry: <span className={styles.n}>3</span>,
</span>
<span className={styles.line}>{"};"}</span>
</span>
</span>
</code>
</pre>
<span className={styles.fade} aria-hidden="true" />
</div>
<div className={styles.toggleRow}>
<button
className={styles.toggle}
type="button"
aria-expanded={open}
aria-controls="ecb-react-more"
onClick={onToggle}
>
<span className={styles.labelWrap}>
<span className={[styles.label, styles.labelMore].join(" ")} aria-hidden={open}>
{moreLabel}
</span>
<span className={[styles.label, styles.labelLess].join(" ")} aria-hidden={!open}>
{lessLabel}
</span>
</span>
<CaretIcon />
</button>
</div>
</div>
<span className={styles.ecbCursor} aria-hidden="true" />
</div>
);
}05 길면 접었다가 펴는 코드 창 — 측정 누적면적 31.724% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창
이 데모가 속한 글
코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창코드 블록은 문서 안에 프로그래밍 언어를 그대로 옮겨 담은 상자이고, 아홉 가지 모두 복사·전환·펼침·타이핑 같은 동작이 실제로 붙어 있는 화면입니다. 이 글의 데모 전부 보기


