02라디오+화살표 슬라이더
숨긴 라디오 버튼 9개와 화살표 모양 라벨만으로 슬라이드를 한 칸씩 넘기는, 스크롤 없이도 되는 가장 고전적인 CSS 캐러셀 방식입니다.
쓰임배너 슬라이더, 수동으로만 넘기는 공지 카드
:checked형제결합자label for

<main class="stage">
<div class="ra is-demo">
<input type="radio" name="ra-tab" id="ra-1" class="ra__radio" checked>
<input type="radio" name="ra-tab" id="ra-2" class="ra__radio">
<input type="radio" name="ra-tab" id="ra-3" class="ra__radio">
<div class="ra__viewport">
<div class="ra__track">
<div class="ra__slide ra__slide--1">
<label class="ra__next" for="ra-2" aria-label="다음 슬라이드">›</label>
</div>
<div class="ra__slide ra__slide--2">
<label class="ra__prev" for="ra-1" aria-label="이전 슬라이드">‹</label>
<label class="ra__next" for="ra-3" aria-label="다음 슬라이드">›</label>
</div>
<div class="ra__slide ra__slide--3">
<label class="ra__prev" for="ra-2" aria-label="이전 슬라이드">‹</label>
</div>
<div class="ra__slide ra__slide--1" aria-hidden="true">
<span class="ra__next" aria-hidden="true">›</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-yellow);
.ra { width: min(360px, 88vw); }
.ra__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 실제 동작: 고른 라디오에 맞춰 트랙 전체가 밀린다 (형제결합자 + :checked, 0줄 JS) ──
// 트랙 끝에는 1번의 복제본(aria-hidden)이 한 장 더 — 예고편 루프의 끝→처음 점프를 숨긴다 (텔레포트 금지)
.ra__viewport { overflow: hidden; border-radius: $r-card; }
.ra__track {
display: flex;
width: 400%;
transition: transform $duration $easing;
}
#ra-1:checked ~ .ra__viewport .ra__track { transform: translateX(0%); }
#ra-2:checked ~ .ra__viewport .ra__track { transform: translateX(-25%); }
#ra-3:checked ~ .ra__viewport .ra__track { transform: translateX(-50%); }
.ra__slide {
flex: 0 0 25%;
aspect-ratio: 16 / 9;
position: relative;
display: grid;
}
.ra__slide--1 { background: linear-gradient(135deg, $subject-ink, #4a3f5c); }
.ra__slide--2 { background: linear-gradient(135deg, $color, #ebdbd6); }
.ra__slide--3 { background: linear-gradient(135deg, $subject-blue, #d6ddeb); }
.ra__prev, .ra__next {
position: absolute; top: 50%; transform: translateY(-50%);
width: $sp-8; height: $sp-8;
display: grid; place-items: center;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 700;
font-size: 20px;
line-height: 1;
font-family: inherit;
cursor: pointer;
box-shadow: $shadow-press;
}
.ra__prev { left: $sp-3; }
.ra__next { right: $sp-3; }
// ── 데모 루프: 클릭 전까지 라디오가 스스로 1→2→3→1 순으로 넘어간다 (checked 는 1번 그대로) ──
// 마지막 칸(-75%)은 1번의 복제본 — 100%→0% 점프가 복제본=1번이라 안 보인다
.ra.is-demo .ra__viewport .ra__track { animation: ra-loop $dur-loop $easing infinite; }
@keyframes ra-loop {
0%, 8% { transform: translateX(0%); }
31%, 39% { transform: translateX(-25%); }
62%, 70% { transform: translateX(-50%); }
92%, 100% { transform: translateX(-75%); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ra__track { transition: none; }
.ra.is-demo .ra__viewport .ra__track { animation: none !important; }
}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);
}
.ra {
width: min(360px, 88vw);
}
.ra__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.ra__viewport {
overflow: hidden;
border-radius: 16px;
}
.ra__track {
display: flex;
width: 400%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#ra-1:checked ~ .ra__viewport .ra__track {
transform: translateX(0%);
}
#ra-2:checked ~ .ra__viewport .ra__track {
transform: translateX(-25%);
}
#ra-3:checked ~ .ra__viewport .ra__track {
transform: translateX(-50%);
}
.ra__slide {
flex: 0 0 25%;
aspect-ratio: 16/9;
position: relative;
display: grid;
}
.ra__slide--1 {
background: linear-gradient(135deg, #17141a, #4a3f5c);
}
.ra__slide--2 {
background: linear-gradient(135deg, #ff4d1f, #ebdbd6);
}
.ra__slide--3 {
background: linear-gradient(135deg, #2f6df6, #d6ddeb);
}
.ra__prev, .ra__next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 700;
font-size: 20px;
line-height: 1;
font-family: inherit;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ra__prev {
left: 12px;
}
.ra__next {
right: 12px;
}
.ra.is-demo .ra__viewport .ra__track {
animation: ra-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ra-loop {
0%, 8% {
transform: translateX(0%);
}
31%, 39% {
transform: translateX(-25%);
}
62%, 70% {
transform: translateX(-50%);
}
92%, 100% {
transform: translateX(-75%);
}
}
@media (prefers-reduced-motion: reduce) {
.ra__track {
transition: none;
}
.ra.is-demo .ra__viewport .ra__track {
animation: none !important;
}
}// 실제 동작은 라디오 :checked + 형제결합자로 트랙을 미는 것만으로 끝난다(0줄).
// 이 3줄은 미리보기 자동 재생만 멈춘다.
document.querySelectorAll('.ra__radio, .ra__prev, .ra__next').forEach(function (el) {
el.addEventListener('pointerdown', function () { document.querySelector('.ra').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadioArrows.module.scss";
export type RadioArrowsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pos"?: string };
const N = 3;
export default function RadioArrows({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#ff4d1f" }: RadioArrowsProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--pos": `${-active * (100 / N)}%` };
return (
<div className={styles.ra} style={vars}>
{/* 원본은 라디오 :checked + 형제결합자로 트랙을 민다(0줄 JS). 리액트 판은 같은 이동을 useState 로 대신한다 */}
<div className={styles.viewport}>
<div className={styles.track}>
{[1, 2, 3].map((n) => (
<div key={n} className={`${styles.slide} ${styles["slide" + n]}`} />
))}
</div>
</div>
<button type="button" className={styles.prev} aria-label="이전 슬라이드" onClick={() => setActive((active - 1 + N) % N)}>‹</button>
<button type="button" className={styles.next} aria-label="다음 슬라이드" onClick={() => setActive((active + 1) % N)}>›</button>
</div>
);
}02 라디오+화살표 슬라이더 — 측정 누적면적 50.374% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 슬라이더로 만드는 캐러셀 9가지 — 복붙 코드
이 데모가 속한 글
CSS 슬라이더로 만드는 캐러셀 9가지 — 복붙 코드CSS 슬라이더는 사진이나 카드 여러 장이 화면 안에서 옆으로 또는 위아래로 순서대로 넘어가며 보이게 하는 화면 부품입니다. 스크롤부터 라디오 트릭까지, 자바스크립트 없이 CSS만으로 만드는 아홉 가지 방식을 그대로 이 글의 데모 전부 보기


