CSS Checkbox/Radio Hack — JS 없이 토글·탭·모달 만들기

👉🏼 KIM·2026년 4월 23일

토글 스위치 구현 방법 찾다가 :checked 해킹을 알게 됐는데, 토글뿐 아니라 탭·모달·아코디언까지 같은 원리로 만들 수 있길래 정리해보았다.

input의 :checked 상태랑 CSS 형제 선택자(+, ~)만으로 JS 없이 토글, 탭, 모달, 아코디언을 만들 수 있다. input을 숨기고, label을 클릭 영역으로 쓰고, :checked에 따라 형제 요소 스타일을 바꾸는 게 핵심. 체크박스는 ON/OFF, 라디오는 택일 상황에 쓰고, 패턴은 총 7가지 정리했다.

1. 핵심 원리

[1] input (시각 hide)
[2] label (실제 클릭 영역, for=input id 매칭)
[3] :checked + sibling selector → 형제 요소의 스타일 변경

핵심 selector

selector의미
input:checked + labelchecked 된 input 바로 다음 label
input:checked ~ .targetchecked 된 input 의 이후 어떤 sibling .target
input:checked + label::beforechecked 된 label 의 가상 요소

input 시각 hide 방법 비교

방법접근성폼 동작추천
display: none❌ 스크린리더 못 읽음✓ submit 됨△ 간단하지만 권장 X
opacity: 0 + position: absolute + pointer-events: none✓ 인식 가능✓ submit 됨★ 권장
visibility: hidden❌ 키보드 탐색 안 됨✓ submit 됨✗ 비권장

2. Checkbox vs Radio — 어떤 걸 쓸까

둘 다 위 핵심 원리(:checked + sibling)로 동일하게 동작하지만, 의미(시멘틱)가 다름.

차이

항목CheckboxRadio
input 개수1개 (단일 토글)같은 name 여러 개 (그룹)
본질ON/OFF (boolean)여러 옵션 중 하나 선택 (택일)
미선택 form 전송안 됨 (체크 시만 name=value 전송)그룹 전체 미전송 (또는 default)
시각 형태보통 토글 스위치 한 개보통 세그먼트 컨트롤 / 라디오 버튼

선택 기준 (한 줄 가이드)

본질이 boolean(켜다/끄다)이면 checkbox, 둘 이상의 명시적 옵션 중 택일이면 radio.

예시

상황시멘틱이유
알림 받음 / 받지않음checkbox"받음=true" boolean. 미체크는 "받지않음"으로 처리
다크모드 ON / OFFcheckbox단순 토글
약관 동의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 데이터 처리 방식이 다름.

  • radio: ?filter=all 또는 ?filter=used (둘 다 명시 값)
  • checkbox: 체크 시 ?used_only=on, 미체크 시 파라미터 없음

의도에 따라 결정. 둘 다 명시 값으로 보내야 하면 radio, 미체크가 기본 의미면 checkbox.

3. 장단점

장점

  • JS 없이 동작 (가벼움, 의존성 없음)
  • form submit 자동 처리 (input 값이 form data에 포함)
  • 접근성 (키보드 탐색, 스크린리더 — opacity 방식 hide 시)
  • 브라우저 호환성 좋음 (모든 모던 브라우저)

단점

  • 마크업 순서 제약: sibling selector라서 input → label → target 순서 필요
  • target은 input의 형제(sibling) 만 가능 (자식 X, 부모 X) — :has() 로 일부 우회 가능
  • 복잡한 인터랙션은 결국 JS 필요

4. 패턴 7가지

패턴 1. 토글 스위치 (ON/OFF)

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); }

패턴 2. 세그먼티드 컨트롤 (radio 택일)

탭처럼 보이지만 본질은 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%);
}

핵심 함정 — indicator 가 label 너비와 안 맞을 때

두 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만 조정
단점: 디자인 시안 정확 매칭 미묘 차이 가능

옵션이 N개일 때 (3개 이상)

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: 1 vs flex: 2 → 1:2 비율.

패턴 3. 아코디언 / 펼치기

체크박스 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 없이도 됨.

패턴 4. 모달 / 팝업 열기·닫기

체크박스로 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; }

패턴 5. 별점 평가

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;
}

패턴 6. 탭 컨텐츠 전환

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가 더 깔끔.

패턴 7. 검색창 확장

체크박스 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; }

5. 주의 사항

항목설명
ID 충돌label for ↔ input id 매칭. 같은 페이지에 여러 개 두면 id 유니크해야 함
마크업 순서sibling selector라서 input → label → target 순서 강제
접근성 hidedisplay: none 보다 opacity: 0 + position: absolute 가 권장 (스크린리더 인식)
form 안에 둘 때submit 시 자동 전송 (name 속성 필수)
상태 관리 복잡 시JS로 가는 게 깔끔. 이 패턴은 단순 toggle 에 최적
:has() 활용부모 selector. 최신 브라우저 지원 (2022+). 마크업 제약 일부 우회 가능

6. 참고

profile
프론트는 순항중 ¿¿

0개의 댓글