08スプリットスライダーヒーロー
スライド3枚が同じマスに重なり、左の文字面と右の絵面が互いに逆方向から拭うように現れて次の枚へ渡します。
使いどころ複数の製品・シーズンを交互に見せる冒頭
clip-pathgrid-areaaria-current

<main class="stage">
<section class="hl is-demo" aria-label="製品紹介スライド">
<!-- 세 장이 같은 격자 칸(grid-area: 1 / 1)에 포개진다. 복제본 없이 z 순서와 위프만으로 순환한다 -->
<div class="hl__deck">
<article class="hl__slide hl__slide--1 is-on">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
春のしおり
</p>
<h2 class="hl__title">軽い上着を一枚</h2>
<p class="hl__sub">朝夕の風よけに</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art1" aria-hidden="true">
<span class="hl__shape hl__shape--disc"></span>
<span class="hl__shape hl__shape--bar"></span>
</div>
</article>
<article class="hl__slide hl__slide--2">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
夏のしおり
</p>
<h2 class="hl__title">日陰まで持ち歩く帽子</h2>
<p class="hl__sub">折りたたんで鞄に入ります</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art2" aria-hidden="true">
<span class="hl__shape hl__shape--arch"></span>
<span class="hl__shape hl__shape--dot"></span>
</div>
</article>
<article class="hl__slide hl__slide--3">
<div class="hl__pane hl__pane--copy">
<p class="hl__kicker">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor"><path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2"/><path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z"/></svg>
秋のしおり
</p>
<h2 class="hl__title">二重に織ったひざ掛け</h2>
<p class="hl__sub">膝の上にちょうどいい大きさ</p>
</div>
<div class="hl__pane hl__pane--art hl__pane--art3" aria-hidden="true">
<span class="hl__shape hl__shape--wedge"></span>
<span class="hl__shape hl__shape--ring"></span>
</div>
</article>
</div>
<div class="hl__dots" role="group" aria-label="スライドを選ぶ">
<button class="hl__dot hl__dot--1" type="button" aria-current="true" aria-label="一枚目へ"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--2" type="button" aria-label="二枚目へ"><span class="hl__fill"></span></button>
<button class="hl__dot hl__dot--3" type="button" aria-label="三枚目へ"><span class="hl__fill"></span></button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 275 히어로 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$hr-ink: $stage-ink; // 오렌지 위 글자 5.50:1 · 그림 면 바닥
$hr-cream: #fdf7ec; // 그림 면 바닥 (잉크 도형 17.10:1)
$hr-orange: $stage-orange; // 무대 · 글자 면 바닥
$hr-yellow: $stage-yellow; // 그림 면 바닥 (잉크 도형 12.63:1)
$hr-violet: #9791c9; // 브랜드 강조 (크림 위 5.75:1)
$hr-lilac: #d8d6eb; // 잉크 위 강조 6.99:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 장이 한 바퀴 도는 시간
$easing: $ease-spring; // 닦이듯 드러나는 위프
$color: $hr-violet; // 이 항목의 강조색
@include stage($hr-orange);
.hl {
display: grid;
gap: $sp-3;
width: min(400px, 90vw);
}
// 세 장을 **같은 격자 칸**에 포갠다 — 복제본 없이 z 순서와 위프만으로 순환한다
.hl__deck {
display: grid;
height: clamp(120px, 46vh, 148px);
overflow: hidden;
border-radius: $r-card;
}
.hl__slide {
grid-area: 1 / 1;
z-index: 1;
display: grid;
grid-template-columns: 1fr 38%;
}
// ── 실제 동작 ① 위프 — 왼쪽 면은 왼쪽에서, 오른쪽 면은 오른쪽에서 반대로 닦인다 ──
// opacity 크로스페이드는 쓰지 않는다: 교차 구간 프레임이 전부 반투명 유령이 된다 (237·266 실측)
.hl__pane {
box-sizing: border-box;
transition: clip-path $dur-enter $easing;
}
.hl__pane--copy { clip-path: inset(0 100% 0 0); }
.hl__pane--art { clip-path: inset(0 0 0 100%); }
// 정적 상태(루프 밖) — 지금 장은 맨 위에서 다 보이고, 직전 장은 그 아래에 그대로 남는다.
// 직전 장을 남겨 둬야 새 장이 덮는 동안 바닥이 비지 않는다
.hl__slide.is-prev { z-index: 2; }
.hl__slide.is-on { z-index: 3; }
.hl__slide.is-prev .hl__pane,
.hl__slide.is-on .hl__pane { clip-path: inset(0 0 0 0); }
.hl__pane--copy {
display: grid;
align-content: center;
gap: $sp-1;
padding: $sp-4;
background: $hr-orange;
color: $hr-ink;
}
.hl__kicker {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
letter-spacing: .02em;
}
.hl__title {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1.25;
letter-spacing: -.02em;
}
.hl__sub {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
// 그림 면 — 도형 조합만. 외부 이미지는 쓰지 않는다
.hl__pane--art {
position: relative;
overflow: hidden;
}
.hl__pane--art1 { background: $hr-ink; }
.hl__pane--art2 { background: $hr-cream; }
.hl__pane--art3 { background: $hr-yellow; }
.hl__shape { position: absolute; display: block; }
.hl__shape--disc {
top: 18%; left: 18%;
width: 52%; height: 52%;
border-radius: 50%;
background: $hr-lilac;
}
.hl__shape--bar {
right: 14%; bottom: 16%;
width: 22%; height: 44%;
border-radius: $r-pill;
background: $hr-yellow;
}
.hl__shape--arch {
left: 22%; bottom: 0;
width: 56%; height: 62%;
border-radius: $r-pill $r-pill 0 0;
background: $hr-violet;
}
.hl__shape--dot {
top: 16%; right: 18%;
width: 22%; height: 22%;
border-radius: 50%;
background: $hr-orange;
}
.hl__shape--wedge {
top: 20%; left: 16%;
width: 46%; height: 60%;
background: $hr-ink;
clip-path: polygon(0 100%, 50% 0, 100% 100%);
}
.hl__shape--ring {
right: 12%; bottom: 18%;
width: 30%; height: 30%;
border: 4px solid $hr-violet;
border-radius: 50%;
}
// ── 점 인디케이터 ─────────────────────────────────────
.hl__dots {
display: flex;
justify-content: center;
gap: $sp-2;
}
.hl__dot {
display: grid;
place-items: center;
width: 14px;
height: 14px;
padding: 0;
border: 2px solid $hr-ink;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.hl__dot:focus-visible { outline: 2px solid $hr-ink; outline-offset: 3px; }
.hl__fill {
width: 6px;
height: 6px;
border-radius: 50%;
background: $hr-ink;
transform: scale(0);
}
// 채움은 보간하지 않는다 — 반쯤 찬 중간 프레임을 만들지 않기 위해 컷으로 바꾼다
.hl__dot[aria-current="true"] .hl__fill { transform: scale(1); }
// ── 격자 미리보기 루프 ────────────────────────────────
// 시차는 --d 하나로 준다 (슬라이드에 걸면 그 안의 면·제목이 물려받는다).
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.hl.is-demo .hl__slide--1, .hl.is-demo .hl__dot--1 { --d: -.6667s; }
.hl.is-demo .hl__slide--2, .hl.is-demo .hl__dot--2 { --d: 0s; }
.hl.is-demo .hl__slide--3, .hl.is-demo .hl__dot--3 { --d: -1.3333s; }
.hl.is-demo .hl__slide {
animation-name: hlZ;
animation-duration: $duration;
animation-timing-function: steps(1, end); // z 순서는 컷으로만 바뀐다
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
// ⚠ steps() 키프레임 안에서는 clip-path 가 보간되지 않는다 — 위프는 이징 애니메이션으로 따로 둔다
.hl.is-demo .hl__pane--copy,
.hl.is-demo .hl__pane--art,
.hl.is-demo .hl__title {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
transition: none;
}
.hl.is-demo .hl__pane--copy { animation-name: hlWipeL; }
.hl.is-demo .hl__pane--art { animation-name: hlWipeR; }
.hl.is-demo .hl__title { animation-name: hlPop; }
.hl.is-demo .hl__fill {
animation-name: hlFill;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
// 맨 위(3) → 바로 아래(2) → 바닥(1). 바닥일 때만 숨은 상태로 되돌린다 — 위 두 장이 덮고 있어 안 보인다
@keyframes hlZ {
0%, 33.32% { z-index: 3; }
33.33%, 66.65% { z-index: 2; }
66.66%, 100% { z-index: 1; }
}
@keyframes hlWipeL {
0% { clip-path: inset(0 100% 0 0); }
18%, 66.65% { clip-path: inset(0 0 0 0); }
66.66%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes hlWipeR {
0% { clip-path: inset(0 0 0 100%); }
18%, 66.65% { clip-path: inset(0 0 0 0); }
66.66%, 100% { clip-path: inset(0 0 0 100%); }
}
// 컷만 있으면 움직인 프레임이 모자란다 — 위프와 함께 제목이 살짝 올라오게 둔다
@keyframes hlPop {
0% { transform: translateY(10px); }
18%, 66.65% { transform: translateY(0); }
66.66%, 100% { transform: translateY(10px); }
}
// 점 채움은 위프가 **끝나는** 18% 에 바뀐다 — 0% 에 바꾸면 아직 앞 장이 보이는데 점만 먼저 옮겨간다.
// 24프레임은 4.1667% 간격이라 18%·51.33% 는 프레임 사이에 떨어져 반쯤 찬 장이 안 생긴다
@keyframes hlFill {
0%, 17.99% { transform: scale(0); }
18%, 51.32% { transform: scale(1); }
51.33%, 100% { transform: scale(0); }
}
// 좁은 화면 — 글자와 여백만 줄인다. 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.hl { gap: $sp-2; }
.hl__pane--copy { padding: $sp-3; }
.hl__kicker { font-size: 10px; }
.hl__sub { font-size: 10px; }
.hl__dot { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .hl.is-demo .hl__pane--copy (0,3,0) 를 이기려면 !important 가 필요하다
.hl__slide, .hl__pane, .hl__title, .hl__fill {
animation: none !important;
transition: none !important;
}
// 움직임만 끄고 상태는 남긴다 — 지금 장은 그대로 다 보인다
.hl__slide.is-on .hl__pane { clip-path: inset(0 0 0 0) !important; }
.hl__title { transform: none !important; }
.hl__dot[aria-current="true"] .hl__fill { transform: scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hl {
display: grid;
gap: 12px;
width: min(400px, 90vw);
}
.hl__deck {
display: grid;
height: clamp(120px, 46vh, 148px);
overflow: hidden;
border-radius: 16px;
}
.hl__slide {
grid-area: 1/1;
z-index: 1;
display: grid;
grid-template-columns: 1fr 38%;
}
.hl__pane {
box-sizing: border-box;
transition: clip-path 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hl__pane--copy {
clip-path: inset(0 100% 0 0);
}
.hl__pane--art {
clip-path: inset(0 0 0 100%);
}
.hl__slide.is-prev {
z-index: 2;
}
.hl__slide.is-on {
z-index: 3;
}
.hl__slide.is-prev .hl__pane,
.hl__slide.is-on .hl__pane {
clip-path: inset(0 0 0 0);
}
.hl__pane--copy {
display: grid;
align-content: center;
gap: 4px;
padding: 16px;
background: #ff4d1f;
color: #17141a;
}
.hl__kicker {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.hl__title {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.2vw, 19px);
line-height: 1.25;
letter-spacing: -0.02em;
}
.hl__sub {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.hl__pane--art {
position: relative;
overflow: hidden;
}
.hl__pane--art1 {
background: #17141a;
}
.hl__pane--art2 {
background: #fdf7ec;
}
.hl__pane--art3 {
background: #ffd23f;
}
.hl__shape {
position: absolute;
display: block;
}
.hl__shape--disc {
top: 18%;
left: 18%;
width: 52%;
height: 52%;
border-radius: 50%;
background: #d8d6eb;
}
.hl__shape--bar {
right: 14%;
bottom: 16%;
width: 22%;
height: 44%;
border-radius: 999px;
background: #ffd23f;
}
.hl__shape--arch {
left: 22%;
bottom: 0;
width: 56%;
height: 62%;
border-radius: 999px 999px 0 0;
background: #9791c9;
}
.hl__shape--dot {
top: 16%;
right: 18%;
width: 22%;
height: 22%;
border-radius: 50%;
background: #ff4d1f;
}
.hl__shape--wedge {
top: 20%;
left: 16%;
width: 46%;
height: 60%;
background: #17141a;
clip-path: polygon(0 100%, 50% 0, 100% 100%);
}
.hl__shape--ring {
right: 12%;
bottom: 18%;
width: 30%;
height: 30%;
border: 4px solid #9791c9;
border-radius: 50%;
}
.hl__dots {
display: flex;
justify-content: center;
gap: 8px;
}
.hl__dot {
display: grid;
place-items: center;
width: 14px;
height: 14px;
padding: 0;
border: 2px solid #17141a;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.hl__dot:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.hl__fill {
width: 6px;
height: 6px;
border-radius: 50%;
background: #17141a;
transform: scale(0);
}
.hl__dot[aria-current=true] .hl__fill {
transform: scale(1);
}
.hl.is-demo .hl__slide--1, .hl.is-demo .hl__dot--1 {
--d: -.6667s;
}
.hl.is-demo .hl__slide--2, .hl.is-demo .hl__dot--2 {
--d: 0s;
}
.hl.is-demo .hl__slide--3, .hl.is-demo .hl__dot--3 {
--d: -1.3333s;
}
.hl.is-demo .hl__slide {
animation-name: hlZ;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
.hl.is-demo .hl__pane--copy,
.hl.is-demo .hl__pane--art,
.hl.is-demo .hl__title {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
transition: none;
}
.hl.is-demo .hl__pane--copy {
animation-name: hlWipeL;
}
.hl.is-demo .hl__pane--art {
animation-name: hlWipeR;
}
.hl.is-demo .hl__title {
animation-name: hlPop;
}
.hl.is-demo .hl__fill {
animation-name: hlFill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: var(--d, 0s);
}
@keyframes hlZ {
0%, 33.32% {
z-index: 3;
}
33.33%, 66.65% {
z-index: 2;
}
66.66%, 100% {
z-index: 1;
}
}
@keyframes hlWipeL {
0% {
clip-path: inset(0 100% 0 0);
}
18%, 66.65% {
clip-path: inset(0 0 0 0);
}
66.66%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes hlWipeR {
0% {
clip-path: inset(0 0 0 100%);
}
18%, 66.65% {
clip-path: inset(0 0 0 0);
}
66.66%, 100% {
clip-path: inset(0 0 0 100%);
}
}
@keyframes hlPop {
0% {
transform: translateY(10px);
}
18%, 66.65% {
transform: translateY(0);
}
66.66%, 100% {
transform: translateY(10px);
}
}
@keyframes hlFill {
0%, 17.99% {
transform: scale(0);
}
18%, 51.32% {
transform: scale(1);
}
51.33%, 100% {
transform: scale(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.hl {
gap: 8px;
}
.hl__pane--copy {
padding: 12px;
}
.hl__kicker {
font-size: 10px;
}
.hl__sub {
font-size: 10px;
}
.hl__dot {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.hl__slide, .hl__pane, .hl__title, .hl__fill {
animation: none !important;
transition: none !important;
}
.hl__slide.is-on .hl__pane {
clip-path: inset(0 0 0 0) !important;
}
.hl__title {
transform: none !important;
}
.hl__dot[aria-current=true] .hl__fill {
transform: scale(1) !important;
}
}(function () {
var root = document.querySelector('.hl');
var slides = [].slice.call(root.querySelectorAll('.hl__slide'));
var dots = [].slice.call(root.querySelectorAll('.hl__dot'));
var cur = 0;
// 점을 누르면 CSS 루프를 끄고 그 장으로 간다. 이때부터는 transition 위프가 맡는다.
// 직전 장은 is-prev 로 한 층 아래에 남겨 둔다 — 새 장이 덮는 동안 바닥이 비지 않게
function showSlide(i) {
root.classList.remove('is-demo');
var prev = cur;
cur = (i + slides.length) % slides.length;
slides.forEach(function (s, n) {
s.classList.toggle('is-prev', n === prev && n !== cur);
s.classList.toggle('is-on', n === cur);
});
dots.forEach(function (d, n) {
if (n === cur) { d.setAttribute('aria-current', 'true'); } else { d.removeAttribute('aria-current'); }
});
}
dots.forEach(function (d, i) {
d.addEventListener('click', function () { showSlide(i); });
// 좌우 화살표로도 옮겨 다닌다 — iframe 안에서 부모가 끌려가지 않게 preventScroll
d.addEventListener('keydown', function (e) {
var to = null;
if (e.key === 'ArrowRight') { to = i + 1; }
if (e.key === 'ArrowLeft') { to = i - 1; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = dots.length - 1; }
if (to === null) { return; }
e.preventDefault();
showSlide(to);
dots[cur].focus({ preventScroll: true });
});
});
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroSplitSlider.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type Slide = {
kicker: string;
title: string;
sub: string;
/** 점을 읽어 줄 이름 */
dotLabel: string;
};
export type HeroSplitSliderProps = {
slides?: Slide[];
label?: string;
dotsLabel?: string;
/** 세 장이 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
/** 강조색 — 그림 면의 아치·고리 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Delay = CSSProperties & { "--d"?: string };
const SparkleIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M194.82,151.43l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0l-20.3-55.09-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3A7.92,7.92,0,0,1,194.82,151.43Z" opacity="0.2" />
<path d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144ZM144,40a8,8,0,0,1,8-8h16V16a8,8,0,0,1,16,0V32h16a8,8,0,0,1,0,16H184V64a8,8,0,0,1-16,0V48H152A8,8,0,0,1,144,40ZM248,88a8,8,0,0,1-8,8h-8v8a8,8,0,0,1-16,0V96h-8a8,8,0,0,1,0-16h8V72a8,8,0,0,1,16,0v8h8A8,8,0,0,1,248,88Z" />
</svg>
);
const DEFAULT_SLIDES: Slide[] = [
{ kicker: "봄 갈피", title: "가벼운 겉옷 한 벌", sub: "아침저녁 바람막이로", dotLabel: "첫째 장으로" },
{ kicker: "여름 갈피", title: "그늘까지 챙기는 모자", sub: "접어서 가방에 넣습니다", dotLabel: "둘째 장으로" },
{ kicker: "가을 갈피", title: "두 겹으로 짠 담요", sub: "무릎 위에 딱 맞는 크기", dotLabel: "셋째 장으로" },
];
/* 시차는 --d 하나로 준다 — 슬라이드에 걸면 그 안의 면·제목이 물려받는다 */
const DELAYS = ["-.6667s", "0s", "-1.3333s"];
const ART = [styles.art1, styles.art2, styles.art3];
export default function HeroSplitSlider({
slides = DEFAULT_SLIDES,
label = "제품 소개 슬라이드",
dotsLabel = "슬라이드 고르기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#5b4bd6",
}: HeroSplitSliderProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [cur, setCur] = useState<number>(0);
const [prev, setPrev] = useState<number>(-1);
const dots = useRef<Array<HTMLButtonElement | null>>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 점을 누르면 CSS 루프를 끄고 그 장으로 간다. 직전 장은 한 층 아래에 남겨 둔다 */
const showSlide = (i: number): void => {
const to = (i + slides.length) % slides.length;
setLoop(false);
setPrev(cur === to ? -1 : cur);
setCur(to);
const el = dots.current ? dots.current[to] : null;
if (el) { el.focus({ preventScroll: true }); }
};
const onKey = (e: KeyEvt, i: number): void => {
let to = -1;
if (e.key === "ArrowRight") { to = i + 1; }
else if (e.key === "ArrowLeft") { to = i - 1 + slides.length; }
else if (e.key === "Home") { to = 0; }
else if (e.key === "End") { to = slides.length - 1; }
if (to < 0) { return; }
e.preventDefault();
showSlide(to);
};
/* is-on 은 루프 중에도 붙여 둔다 — reduced-motion 으로 애니메이션이 꺼져도 한 장은 보여야 한다 */
const slideClass = (i: number): string => {
const c = [styles.slide];
if (i === cur) { c.push(styles.isOn); }
if (!loop && i === prev) { c.push(styles.isPrev); }
return c.join(" ");
};
return (
<section
className={loop ? `${styles.hl} ${styles.isDemo}` : styles.hl}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
>
{/* 세 장이 같은 격자 칸(grid-area: 1 / 1)에 포개진다 — 복제본 없이 z 순서와 위프만으로 순환한다 */}
<div className={styles.deck}>
{slides.map((s: Slide, i: number) => (
<article className={slideClass(i)} key={s.title} style={{ "--d": DELAYS[i % DELAYS.length] } as Delay}>
<div className={`${styles.pane} ${styles.copy}`}>
<p className={styles.kicker}><SparkleIcon />{s.kicker}</p>
<h2 className={styles.title}>{s.title}</h2>
<p className={styles.sub}>{s.sub}</p>
</div>
<div className={`${styles.pane} ${styles.art} ${ART[i % ART.length]}`} aria-hidden="true">
<span className={`${styles.shape} ${styles.s1}`} />
<span className={`${styles.shape} ${styles.s2}`} />
</div>
</article>
))}
</div>
<div className={styles.dots} role="group" aria-label={dotsLabel}>
{slides.map((s: Slide, i: number) => (
<button
className={styles.dot}
type="button"
key={s.dotLabel}
style={{ "--d": DELAYS[i % DELAYS.length] } as Delay}
aria-label={s.dotLabel}
aria-current={!loop && i === cur ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { if (dots.current) { dots.current[i] = el; } }}
onClick={() => showSlide(i)}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.fill} />
</button>
))}
</div>
</section>
);
}08 スプリットスライダーヒーロー — 計測 累積面積 17.644% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ファーストビュー デザイン 9パターン、コピペで置ける
このデモが属する記事
ファーストビュー デザイン 9パターン、コピペで置けるファーストビュー デザインとは、ページを開いた最初の一画面全体の置き方のことです。ここでは九つのファーストビューを、貼った場所でそのまま動くHTMLとSCSSで載せました。どれも外部のファイルを一つも読み込まず、動きはCSSだけで組んであり この記事のデモをすべて見る


