05치는 대로 쌓이는 옆칸 요약
왼쪽 칸에 글자를 넣으면 오른쪽 요약 줄이 같은 순간 바뀌고, 요약 줄의 고치기 단추를 누르면 그 값을 담은 단계로 곧장 돌아가 해당 칸에 초점이 갑니다.
쓰임주문 정보를 옆에 띄우는 결제 절차, 입력한 값을 계속 확인시켜야 하는 견적 폼
aria-livetranslateXsteps(1, end)

<main class="stage">
<section class="sp is-demo" id="sp" aria-label="배송 정보 입력 단계">
<div class="sp__bar">
<p class="sp__h">배송 정보</p>
<p class="sp__cnt" id="sp-step">2 / 3단계</p>
</div>
<div class="sp__cols">
<div class="sp__form">
<div class="sp__f" data-f="1">
<span class="sp__num" aria-hidden="true">1</span>
<input class="sp__inp" id="sp-i1" value="홍길동" aria-label="받는 사람" placeholder="홍길동">
</div>
<div class="sp__f is-current" data-f="2">
<span class="sp__num" aria-hidden="true">2</span>
<input class="sp__inp" id="sp-i2" value="[email protected]" aria-label="이메일" placeholder="[email protected]">
</div>
<div class="sp__f" data-f="3">
<span class="sp__num" aria-hidden="true">3</span>
<input class="sp__inp" id="sp-i3" value="010-2345-6789" aria-label="연락처" placeholder="010-2345-6789">
</div>
</div>
<div class="sp__side" aria-live="polite">
<ul class="sp__list">
<li class="sp__row" data-row="1">
<span class="sp__k">받는 사람</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">홍길동</span></span>
<button class="sp__fix" type="button">고치기</button>
</li>
<li class="sp__row" data-row="2">
<span class="sp__k">이메일</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">[email protected]</span></span>
<button class="sp__fix" type="button">고치기</button>
</li>
<li class="sp__row" data-row="3">
<span class="sp__k">연락처</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">010-2345-6789</span></span>
<button class="sp__fix" type="button">고치기</button>
</li>
</ul>
</div>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.sp {
box-sizing: border-box;
width: min(320px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sp__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.sp__h { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: rgba(255, 255, 255, .56); }
.sp__cnt { margin: 0; font-size: 11px; font-weight: 700; color: $subject-blue-light; }
.sp__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-3;
}
// ── 왼쪽: 단계 칸 ────────────────────────────────
.sp__form { display: grid; gap: $sp-2; align-content: start; }
.sp__f {
display: grid;
grid-template-columns: auto 1fr;
gap: $sp-2;
align-items: center;
}
.sp__num {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid $line;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, .5);
transition: background-color $duration $easing, border-color $duration $easing, color $duration $easing;
}
.sp__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: $sp-1;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.sp__inp:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// 현재 단계 표시 — 고치기로 돌아온 단계가 어디인지 번호와 테두리로 보인다
.sp__f.is-current .sp__num { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.sp__f.is-current .sp__inp { border-color: $color; }
// ── 오른쪽: 요약 패널 ────────────────────────────
.sp__side { min-width: 0; border-left: 1px solid $line; padding-left: $sp-2; }
.sp__list { display: grid; gap: $sp-2; margin: 0; padding: 0; list-style: none; align-content: start; }
.sp__row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 $sp-1;
align-items: center;
}
.sp__k { grid-column: 1 / -1; font-size: 10px; color: rgba(255, 255, 255, .72); }
.sp__v { position: relative; display: block; min-width: 0; font-size: 11px; }
.sp__v0, .sp__v1 { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.sp__v0 { color: rgba(255, 255, 255, .34); }
.sp__v1 { position: absolute; inset: 0; color: $subject-cream; }
// 정적 상태(사람이 조작하는 길) — 요약 줄에는 이미 값이 들어와 있다
.sp__v0 { opacity: 0; }
.sp__v1 { opacity: 1; }
.sp__fix {
margin: 0;
padding: 0 $sp-1;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: rgba(255, 255, 255, .56);
font-family: inherit;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
transition: border-color $duration $easing, color $duration $easing;
}
.sp__fix:hover { border-color: $color; color: $subject-cream; }
.sp__fix:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// 격자 미리보기 동안 플레이스홀더가 값과 겹쳐 보이지 않게
.sp.is-demo .sp__inp::placeholder { color: transparent; }
// ── 격자 미리보기 전용 2초 루프 ──────────────────
// 값 글자는 steps(1, end) 로 한 프레임에 컷 교체되고(반투명 유령 금지),
// 같은 순간 translateX 로 오른쪽에서 미끄러져 들어와 프레임 수를 채운다
// 값은 한 번 들어오면 루프 끝까지 유지되고, 처음 상태로 돌아가는 순간(컷)은
// 애니메이션 경계(100% → 0%) 자체다 — 루프 안에서 주인공(요약 값)을 지우지 않는다
@keyframes sp-cut-1 {
0%, 13% { opacity: 0; }
14%, 100% { opacity: 1; }
}
@keyframes sp-cut-2 {
0%, 43% { opacity: 0; }
44%, 100% { opacity: 1; }
}
@keyframes sp-cut-3 {
0%, 73% { opacity: 0; }
74%, 100% { opacity: 1; }
}
@keyframes sp-empty-1 {
0%, 13% { opacity: 1; }
14%, 100% { opacity: 0; }
}
@keyframes sp-empty-2 {
0%, 43% { opacity: 1; }
44%, 100% { opacity: 0; }
}
@keyframes sp-empty-3 {
0%, 73% { opacity: 1; }
74%, 100% { opacity: 0; }
}
@keyframes sp-slide-1 {
0%, 12% { transform: translateX(0); }
14% { transform: translateX(10px); }
28%, 100% { transform: translateX(0); }
}
@keyframes sp-slide-2 {
0%, 42% { transform: translateX(0); }
44% { transform: translateX(10px); }
58%, 100% { transform: translateX(0); }
}
@keyframes sp-slide-3 {
0%, 72% { transform: translateX(0); }
74% { transform: translateX(10px); }
88%, 100% { transform: translateX(0); }
}
.sp.is-demo {
.sp__row[data-row="1"] .sp__v1 { animation-name: sp-cut-1, sp-slide-1; }
.sp__row[data-row="2"] .sp__v1 { animation-name: sp-cut-2, sp-slide-2; }
.sp__row[data-row="3"] .sp__v1 { animation-name: sp-cut-3, sp-slide-3; }
.sp__v1 {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.sp__row[data-row="1"] .sp__v0 { animation-name: sp-empty-1; }
.sp__row[data-row="2"] .sp__v0 { animation-name: sp-empty-2; }
.sp__row[data-row="3"] .sp__v0 { animation-name: sp-empty-3; }
.sp__v0 {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
// 320px 에서는 요약을 아래로 내리지 않고 폭만 줄인다 — 세로 예산(174px) 때문
@media (max-width: 340px) {
.sp { padding: $sp-2; }
.sp__bar { margin-bottom: $sp-1; }
.sp__cols { gap: $sp-2; }
.sp__form, .sp__list { gap: $sp-1; }
.sp__f { gap: $sp-1; }
.sp__inp { padding: 0 $sp-1; font-size: 11px; }
.sp__side { padding-left: $sp-1; }
.sp__fix { padding: 0; border: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__v0,
.sp.is-demo .sp__v1 { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과(값이 들어와 있는 요약)는 정적 규칙 그대로 남는다
}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);
}
.sp {
box-sizing: border-box;
width: min(320px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sp__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
}
.sp__h {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.56);
}
.sp__cnt {
margin: 0;
font-size: 11px;
font-weight: 700;
color: #5b8cf8;
}
.sp__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.sp__form {
display: grid;
gap: 8px;
align-content: start;
}
.sp__f {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
}
.sp__num {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 4px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 8px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.sp__inp:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.sp__f.is-current .sp__num {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.sp__f.is-current .sp__inp {
border-color: #2f6df6;
}
.sp__side {
min-width: 0;
border-left: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
padding-left: 8px;
}
.sp__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
align-content: start;
}
.sp__row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 4px;
align-items: center;
}
.sp__k {
grid-column: 1/-1;
font-size: 10px;
color: rgba(255, 255, 255, 0.72);
}
.sp__v {
position: relative;
display: block;
min-width: 0;
font-size: 11px;
}
.sp__v0, .sp__v1 {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sp__v0 {
color: rgba(255, 255, 255, 0.34);
}
.sp__v1 {
position: absolute;
inset: 0;
color: #fff7e6;
}
.sp__v0 {
opacity: 0;
}
.sp__v1 {
opacity: 1;
}
.sp__fix {
margin: 0;
padding: 0 4px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
font-family: inherit;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__fix:hover {
border-color: #2f6df6;
color: #fff7e6;
}
.sp__fix:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.sp.is-demo .sp__inp::placeholder {
color: transparent;
}
@keyframes sp-cut-1 {
0%, 13% {
opacity: 0;
}
14%, 100% {
opacity: 1;
}
}
@keyframes sp-cut-2 {
0%, 43% {
opacity: 0;
}
44%, 100% {
opacity: 1;
}
}
@keyframes sp-cut-3 {
0%, 73% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
@keyframes sp-empty-1 {
0%, 13% {
opacity: 1;
}
14%, 100% {
opacity: 0;
}
}
@keyframes sp-empty-2 {
0%, 43% {
opacity: 1;
}
44%, 100% {
opacity: 0;
}
}
@keyframes sp-empty-3 {
0%, 73% {
opacity: 1;
}
74%, 100% {
opacity: 0;
}
}
@keyframes sp-slide-1 {
0%, 12% {
transform: translateX(0);
}
14% {
transform: translateX(10px);
}
28%, 100% {
transform: translateX(0);
}
}
@keyframes sp-slide-2 {
0%, 42% {
transform: translateX(0);
}
44% {
transform: translateX(10px);
}
58%, 100% {
transform: translateX(0);
}
}
@keyframes sp-slide-3 {
0%, 72% {
transform: translateX(0);
}
74% {
transform: translateX(10px);
}
88%, 100% {
transform: translateX(0);
}
}
.sp.is-demo .sp__row[data-row="1"] .sp__v1 {
animation-name: sp-cut-1, sp-slide-1;
}
.sp.is-demo .sp__row[data-row="2"] .sp__v1 {
animation-name: sp-cut-2, sp-slide-2;
}
.sp.is-demo .sp__row[data-row="3"] .sp__v1 {
animation-name: sp-cut-3, sp-slide-3;
}
.sp.is-demo .sp__v1 {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.sp.is-demo .sp__row[data-row="1"] .sp__v0 {
animation-name: sp-empty-1;
}
.sp.is-demo .sp__row[data-row="2"] .sp__v0 {
animation-name: sp-empty-2;
}
.sp.is-demo .sp__row[data-row="3"] .sp__v0 {
animation-name: sp-empty-3;
}
.sp.is-demo .sp__v0 {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sp {
padding: 8px;
}
.sp__bar {
margin-bottom: 4px;
}
.sp__cols {
gap: 8px;
}
.sp__form, .sp__list {
gap: 4px;
}
.sp__f {
gap: 4px;
}
.sp__inp {
padding: 0 4px;
font-size: 11px;
}
.sp__side {
padding-left: 4px;
}
.sp__fix {
padding: 0;
border: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__v0,
.sp.is-demo .sp__v1 {
animation: none !important;
transform: none !important;
}
}// 실제 동작: 왼쪽 칸에 넣는 글자가 input 이벤트로 그 순간 오른쪽 요약 줄에 반영되고,
// 고치기 단추는 그 값을 담은 단계를 현재 단계로 만들어 해당 칸에 초점을 옮긴다.
var sp = document.getElementById('sp');
var STEPS = ['1 / 3단계', '2 / 3단계', '3 / 3단계'];
var fs = [].slice.call(sp.querySelectorAll('.sp__f'));
var ins = [].slice.call(sp.querySelectorAll('.sp__inp'));
var rows = [].slice.call(sp.querySelectorAll('.sp__row'));
var cnt = document.getElementById('sp-step');
function setStep(i) {
fs.forEach(function (f, n) { f.classList.toggle('is-current', n === i); });
cnt.textContent = STEPS[i];
}
ins.forEach(function (inp, i) {
inp.addEventListener('input', function () {
sp.classList.remove('is-demo');
rows[i].querySelector('.sp__v1').textContent = inp.value.trim() || '—';
});
inp.addEventListener('focus', function () { sp.classList.remove('is-demo'); setStep(i); });
inp.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (d && ins[i + d]) { e.preventDefault(); ins[i + d].focus({ preventScroll: true }); }
});
});
rows.forEach(function (row, i) {
row.querySelector('.sp__fix').addEventListener('click', function () {
sp.classList.remove('is-demo');
setStep(i);
ins[i].focus({ preventScroll: true });
});
});import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LiveSummaryPanel.module.scss";
export type LiveSummaryPanelProps = {
heading?: string;
fields?: { label: string; value: string }[];
editLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FocusEl = { focus(opt: { preventScroll: boolean }): void };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type InpEvt = { target: { value: string } };
const DEFAULT_FIELDS = [
{ label: "Recipient", value: "Hong Gildong" },
{ label: "Email", value: "[email protected]" },
{ label: "Phone", value: "010-2345-6789" },
];
export default function LiveSummaryPanel({
heading = "Delivery details",
fields = DEFAULT_FIELDS,
editLabel = "Edit",
groupLabel = "Delivery details entry steps",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LiveSummaryPanelProps) {
// 정적 상태: 요약이 값으로 차 있고 현재 단계는 2 (0 기반 1)
const [values, setValues] = useState<string[]>(fields.map((f) => f.value));
const [at, setAt] = useState(1);
const [live, setLive] = useState(demo);
const boxes = useRef<(FocusEl | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function type(i: number, v: string) {
setLive(false);
setValues((prev) => prev.map((s, n) => (n === i ? v : s)));
}
// 고치기: 그 값을 담은 단계로 돌아가 해당 칸에 초점을 준다
function jump(i: number) {
setLive(false);
setAt(i);
const arr = boxes.current;
const el = arr ? arr[i] : null;
if (el) el.focus({ preventScroll: true });
}
function arrows(i: number, e: KeyEvt) {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (d && fields[i + d]) {
e.preventDefault();
setAt(i + d);
const arr = boxes.current;
const el = arr ? arr[i + d] : null;
if (el) el.focus({ preventScroll: true });
}
}
const cls = [styles.sp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.bar}>
<p className={styles.h}>{heading}</p>
<p className={styles.cnt}>{at + 1} / {fields.length} steps</p>
</div>
<div className={styles.cols}>
<div className={styles.form}>
{fields.map((f, i) => (
<div
key={f.label}
className={[styles.f, i === at ? styles.isCurrent : ""].filter(Boolean).join(" ")}
data-f={i + 1}
>
<span className={styles.num} aria-hidden="true">{i + 1}</span>
<input
className={styles.inp}
ref={(el: FocusEl | null) => { const arr = boxes.current; if (arr) arr[i] = el; }}
value={values[i]}
aria-label={f.label}
placeholder={f.value}
onChange={(e: InpEvt) => type(i, e.target.value)}
onFocus={() => { setLive(false); setAt(i); }}
onKeyDown={(e: KeyEvt) => arrows(i, e)}
/>
</div>
))}
</div>
<div className={styles.side} aria-live="polite">
<ul className={styles.list}>
{fields.map((f, i) => (
<li key={f.label} className={styles.row} data-row={i + 1}>
<span className={styles.k}>{f.label}</span>
<span className={styles.v}>
<span className={styles.v0}>—</span>
<span className={styles.v1}>{values[i] || "—"}</span>
</span>
<button className={styles.fix} type="button" onClick={() => jump(i)}>{editLabel}</button>
</li>
))}
</ul>
</div>
</div>
</section>
);
}05 치는 대로 쌓이는 옆칸 요약 — 측정 누적면적 1.076% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼
이 데모가 속한 글
회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼회원가입 단계 ui는 한 번에 다 보여 주지 않고 몇 장의 화면으로 쪼개어 순서대로 받아 내는 입력 방식입니다. 아홉 개 전부 값을 실제로 읽고 검사하고 저장하며, 칸을 채우지 않으면 다음 장으로 안 넘어가고 탭을 이 글의 데모 전부 보기


