토글 스위치 구현 방법 찾다가 :checked 해킹을 알게 됐는데, 토글뿐 아니라 탭·모달·아코디언까지 같은 원리로 만들 수 있길래 정리해보았다.
input의 :checked 상태랑 CSS 형제 선택자(+, ~)만으로 JS 없이 토글, 탭, 모달, 아코디언을 만들 수 있다. input을 숨기고, label을 클릭 영역으로 쓰고, :checked에 따라 형제 요소 스타일을 바꾸는 게 핵심. 체크박스는 ON/OFF, 라디오는 택일 상황에 쓰고, 패턴은 총 7가지 정리했다.
[1] input (시각 hide)
[2] label (실제 클릭 영역, for=input id 매칭)
[3] :checked + sibling selector → 형제 요소의 스타일 변경
| selector | 의미 |
|---|---|
input:checked + label | checked 된 input 바로 다음 label |
input:checked ~ .target | checked 된 input 의 이후 어떤 sibling .target |
input:checked + label::before | checked 된 label 의 가상 요소 |
| 방법 | 접근성 | 폼 동작 | 추천 |
|---|---|---|---|
display: none | ❌ 스크린리더 못 읽음 | ✓ submit 됨 | △ 간단하지만 권장 X |
opacity: 0 + position: absolute + pointer-events: none | ✓ 인식 가능 | ✓ submit 됨 | ★ 권장 |
visibility: hidden | ❌ 키보드 탐색 안 됨 | ✓ submit 됨 | ✗ 비권장 |
둘 다 위 핵심 원리(:checked + sibling)로 동일하게 동작하지만, 의미(시멘틱)가 다름.
| 항목 | Checkbox | Radio |
|---|---|---|
| input 개수 | 1개 (단일 토글) | 같은 name 여러 개 (그룹) |
| 본질 | ON/OFF (boolean) | 여러 옵션 중 하나 선택 (택일) |
| 미선택 form 전송 | 안 됨 (체크 시만 name=value 전송) | 그룹 전체 미전송 (또는 default) |
| 시각 형태 | 보통 토글 스위치 한 개 | 보통 세그먼트 컨트롤 / 라디오 버튼 |
본질이 boolean(켜다/끄다)이면 checkbox, 둘 이상의 명시적 옵션 중 택일이면 radio.
| 상황 | 시멘틱 | 이유 |
|---|---|---|
| 알림 받음 / 받지않음 | checkbox | "받음=true" boolean. 미체크는 "받지않음"으로 처리 |
| 다크모드 ON / OFF | checkbox | 단순 토글 |
| 약관 동의 | checkbox | 동의함 = true |
| 결제수단 (카드 / 계좌이체 / 간편결제) | radio | 셋 중 하나 명시적 선택 |
| 성별 (남 / 여) | radio | 두 명시적 값 |
| 필터 (전체 / 중고) | radio | "전체"도 "중고"도 명시적 선택값 |
| 사이즈 (S / M / L) | radio | 택일 |
"전체/중고" 같이 두 옵션이라면 둘 다 구현 가능:
<!-- radio (택일이 명확) -->
<input type="radio" name="filter" value="all">전체
<input type="radio" name="filter" value="used">중고
<!-- checkbox (단순화 — "중고만 보기" 의미) -->
<input type="checkbox" name="used_only">중고만 보기
→ form 데이터 처리 방식이 다름.
?filter=all 또는 ?filter=used (둘 다 명시 값)?used_only=on, 미체크 시 파라미터 없음의도에 따라 결정. 둘 다 명시 값으로 보내야 하면 radio, 미체크가 기본 의미면 checkbox.
:has() 로 일부 우회 가능iOS 스타일 토글.
언제 쓰나: 알림 ON/OFF, 다크모드, 설정값 토글
<div class="toggle">
<input type="checkbox" id="alarm" name="alarm" class="toggle__input">
<label for="alarm" class="toggle__label">
<span class="toggle__knob"></span>
</label>
</div>
.toggle__input { position: absolute; opacity: 0; pointer-events: none; }
.toggle__label {
position: relative; display: inline-block;
width: 40px; height: 24px;
background: #ccc; border-radius: 999px;
cursor: pointer; transition: background 0.25s;
}
.toggle__knob {
position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px;
background: #fff; border-radius: 50%;
transition: transform 0.25s;
}
/* :checked 시 */
.toggle__input:checked + .toggle__label { background: #169DAB; }
.toggle__input:checked + .toggle__label .toggle__knob { transform: translateX(16px); }
탭처럼 보이지만 본질은 radio.
언제 쓰나: 필터 (전체/중고), 정렬 옵션, 탭 메뉴
<div class="segment">
<input type="radio" name="filter" id="all" value="all" checked>
<label for="all">전체</label>
<input type="radio" name="filter" id="used" value="used">
<label for="used">중고</label>
<span class="segment__indicator"></span>
</div>
.segment { position: relative; display: inline-flex; padding: 2px;
background: #fff; border: 1px solid #ddd; border-radius: 999px; }
.segment input { position: absolute; opacity: 0; pointer-events: none; }
.segment label { position: relative; z-index: 1; padding: 6px 16px;
color: #999; cursor: pointer; transition: color 0.2s; white-space: nowrap; }
.segment input:checked + label { color: #fff; }
.segment__indicator { position: absolute; top: 2px; bottom: 2px; left: 2px;
width: calc(50% - 2px);
background: #222; border-radius: 999px;
transition: transform 0.25s; pointer-events: none; }
/* "중고" 체크 시 indicator 우측으로 */
.segment input[value="used"]:checked ~ .segment__indicator {
transform: translateX(100%);
}
두 label 자연 너비가 다르거나 (글자 길이 차이), flex: 1 안 주면 indicator 가 label 너비랑 어긋남.
해결 두 가지:
1. container width 명시 + label flex: 1 (옵션 고정일 때 권장)
.segment {
width: 84px;
height: 32px;
box-sizing: border-box;
}
.segment label { flex: 1; } /* 균등 50% */
.segment__indicator { width: calc(50% - 2px); } /* container padding 보정 */
장점: 디자인 시안과 정확 매칭, indicator 완벽 정렬
단점: 컴포넌트마다 width/height 매번 명시
2. 가변 (label padding으로 사이즈) (옵션 동적일 때)
.segment label { flex: 1; padding: 7px 14px; }
장점: 글자 길이/옵션 추가 시 자동 대응, padding만 조정
단점: 디자인 시안 정확 매칭 미묘 차이 가능
flex: 1 은 자식 수에 따라 균등 분배 (반반 한정 X). 옵션 N개면 각 100/N %.
.segment label { flex: 1; } /* 자동으로 100/N % 균등 */
.segment__indicator { width: calc(100% / 3 - 2px); } /* 3개면 33.33% */
/* transform은 인덱스 × 100% */
.segment input[value="opt2"]:checked ~ .segment__indicator {
transform: translateX(100%);
}
.segment input[value="opt3"]:checked ~ .segment__indicator {
transform: translateX(200%);
}
비균등 비율 원하면 flex 값 다르게:
flex: 1vsflex: 2→ 1:2 비율.
체크박스 toggle 로 컨텐츠 show/hide. (또는 HTML5 <details> 사용 가능)
언제 쓰나: FAQ, 약관 펼치기, 설정 그룹
<div class="accordion">
<input type="checkbox" id="faq1" class="accordion__input">
<label for="faq1" class="accordion__title">자주 묻는 질문 1</label>
<div class="accordion__content">
답변 내용입니다.
</div>
</div>
.accordion__input { display: none; } /* 아코디언은 display:none 무방 (form 데이터 의미 없음) */
.accordion__title { display: block; padding: 16px;
background: #f5f5f5; cursor: pointer; }
.accordion__content {
max-height: 0; /* 닫힌 상태 */
overflow: hidden;
transition: max-height 0.3s ease;
}
/* checked 시 펼침 */
.accordion__input:checked ~ .accordion__content {
max-height: 500px; /* 충분히 큰 값 */
}
더 단순한 버전: HTML5
<details>+<summary>— 아예 input 없이도 됨.
체크박스로 overlay show/hide. JS 없이 모달.
언제 쓰나: 햄버거 메뉴, 간단한 팝업, lightbox
<input type="checkbox" id="modal" class="modal__input">
<label for="modal" class="modal__open">팝업 열기</label>
<div class="modal__overlay">
<div class="modal__box">
팝업 내용
<label for="modal" class="modal__close">X</label>
</div>
</div>
.modal__input { display: none; }
.modal__overlay {
display: none;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 1000;
}
.modal__input:checked ~ .modal__overlay { display: flex; }
radio 5개 + label로 별. CSS만으로 별점 UI.
언제 쓰나: 리뷰 별점, 만족도 평가
<div class="rating">
<input type="radio" name="rate" id="r5" value="5"><label for="r5">★</label>
<input type="radio" name="rate" id="r4" value="4"><label for="r4">★</label>
<input type="radio" name="rate" id="r3" value="3"><label for="r3">★</label>
<input type="radio" name="rate" id="r2" value="2"><label for="r2">★</label>
<input type="radio" name="rate" id="r1" value="1"><label for="r1">★</label>
</div>
.rating {
display: inline-flex;
flex-direction: row-reverse; /* 우→좌 정렬 (호버/체크 ~로 좌측 별 채우기 위해) */
}
.rating input { display: none; }
.rating label {
color: #ccc; font-size: 24px; cursor: pointer;
}
/* 체크된 별 + 그 좌측(=row-reverse 기준 ~) 모두 노란색 */
.rating input:checked ~ label,
.rating label:hover,
.rating label:hover ~ label {
color: #ffc107;
}
radio + 탭별 컨텐츠 swap. JS 없이 탭.
언제 쓰나: 상품 상세 탭(상세/리뷰/Q&A), 카테고리 탭
<div class="tabs">
<input type="radio" name="tab" id="tab1" checked><label for="tab1">탭1</label>
<input type="radio" name="tab" id="tab2"><label for="tab2">탭2</label>
<div class="tabs__content tab1">탭1 내용</div>
<div class="tabs__content tab2">탭2 내용</div>
</div>
.tabs__content { display: none; }
#tab1:checked ~ .tab1,
#tab2:checked ~ .tab2 { display: block; }
단점: id 가 글로벌이라 페이지에 같은 패턴 여러 개 두기 까다로움. 컴포넌트 단위로 쓸 때는 JS가 더 깔끔.
체크박스 toggle로 input 펼치기.
언제 쓰나: 헤더 검색 아이콘 → 입력창 확장
<input type="checkbox" id="search-toggle" class="search__toggle">
<label for="search-toggle" class="search__icon">🔍</label>
<div class="search__form">
<input type="text" placeholder="검색어 입력">
</div>
.search__toggle { display: none; }
.search__form {
width: 0; overflow: hidden;
transition: width 0.3s ease;
}
.search__toggle:checked ~ .search__form { width: 200px; }
| 항목 | 설명 |
|---|---|
| ID 충돌 | label for ↔ input id 매칭. 같은 페이지에 여러 개 두면 id 유니크해야 함 |
| 마크업 순서 | sibling selector라서 input → label → target 순서 강제 |
| 접근성 hide | display: none 보다 opacity: 0 + position: absolute 가 권장 (스크린리더 인식) |
| form 안에 둘 때 | submit 시 자동 전송 (name 속성 필수) |
| 상태 관리 복잡 시 | JS로 가는 게 깔끔. 이 패턴은 단순 toggle 에 최적 |
:has() 활용 | 부모 selector. 최신 브라우저 지원 (2022+). 마크업 제약 일부 우회 가능 |