웹 접근성으로 모두를 품은 컴포넌트 설계
웹 접근성으로 모두를 품은 컴포넌트 설계
디자인 시안에서 정해진 커스텀 스타일이 있었지만, 기본 input 요소(checkbox, radio)로는 이를 구현할 수 없었습니다. 네이티브 요소 대신 커스텀 디자인을 적용하면서도 웹 접근성을 지키기 위해 WAI-ARIA 패턴을 도입한 공통 컴포넌트를 설계했습니다.
목표는 다음과 같았습니다.
- 스크린 리더와 키보드 사용자도 동등하게 사용할 수 있게 하기
- 커스텀 디자인과 접근성을 동시에 충족
- 재사용 가능한 공통 컴포넌트로 개발 효율성 높이기
개발 과정
1. 문제 인식과 접근법 결정
프로젝트에서 디자이너가 제시한 시안은 기본 input 요소의 스타일로는 구현이 불가능했습니다.
체크박스, 라디오 디자인 시안
기본 input을 사용하면 CSS 커스텀이 제한적이어서, 네이티브 <input>을 숨기고 <span>으로 커스텀 UI를 설계하기로 했습니다. 하지만 이 과정에서 접근성이 깨질 수 있다는 우려가 생겼고, 이를 해결하기 위해 WAI-ARIA 패턴을 적용하는 방향을 택했습니다.
- 왜
<span>인가?: 네이티브<input>을 숨기고<span>에role="checkbox"와role="radio"를 부여해 커스텀 디자인을 자유롭게 구현하면서도 스크린 리더가 인식할 수 있게 했습니다. - WAI-ARIA 선택 이유:
<input>을 대체하면서도 키보드와 스크린 리더 지원을 보장하려면 ARIA 속성이 필수였습니다.
2. WAI-ARIA 패턴 적용
WAI-ARIA의 체크박스와 라디오 패턴을 참고해 다음과 같이 구현했습니다.
- 고유 ID 연결:
useId로 고유 ID를 생성해<input>과<label>을 연결. 스크린 리더가 라벨과 요소를 명확히 연관 짓게 함 - ARIA 속성:
aria-checked로 상태 전달,aria-disabled로 비활성 상태 알림,aria-label은labelprop이 없을 때 기본 설명 제공 - 키보드 이벤트:
Space와Enter키로 토글 가능하도록onKeyDown핸들러 직접 작성.e.preventDefault()로 기본 동작 방지하며 키보드 사용성 강화
3. 커스텀 디자인과 접근성 조화
- 숨김 처리: 네이티브
<input>에className="hidden"적용해 시각적 중복 제거 - 포커스 스타일:
focus:ring으로 키보드 사용자가 포커스 상태를 인지하도록 시각적 피드백 추가 - 상태 반영:
checked상태에 따라 아이콘 변경하며 스크린 리더와 동기화(aria-checked)
4. 코드 최적화와 재사용성
CheckboxInput과 RadioInput을 공통 컴포넌트로 설계하며 재사용성을 높였습니다.
- props 확장:
React.InputHTMLAttributes상속으로 다양한 속성 지원 - variant 옵션:
CheckboxInput에checkbox와check스타일 변형 추가로 유연성 확보 - 모듈화: 반복 로직(
handleToggle)을 함수로 분리해 유지보수성 개선
체크박스 및 라디오 인풋 WAI-ARIA 적용 코드
CheckboxInput
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const CheckboxInput = ({ label, checked, disabled, onChange }) => {
const id = useId();
return (
<div>
<input
id={checkboxId}
type="checkbox"
checked={checked}
onChange={onChange}
disabled={disabled}
{...props}
className="hidden"
/>
<span
role="checkbox"
tabIndex={0}
aria-checked={checked}
aria-labelledby={checkboxId}
aria-disabled={disabled}
onKeyDown={(e) => handleKeyDown(e, handleToggle, disabled)}
onClick={!disabled ? handleToggle : undefined}
className="focus:outline-none focus:ring-1 focus:ring-primary-normal cursor-pointer"
>
{getIconForState(variant, checked)}
</span>
<label htmlFor={checkboxId}>
{label}
</label>
</div>
);
};
RadioInput
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const RadioInput = ({ label, checked, disabled, onChange }) => {
const id = useId();
return (
<label htmlFor={`radio-${id}`}>
<input
type='radio'
checked={checked}
id={`radio-${id}`}
onChange={onChange}
disabled={disabled}
className="hidden"
/>
<span
role="radio"
tabIndex={0}
aria-checked={checked}
aria-disabled={disabled}
onKeyDown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !disabled && !checked) {
e.preventDefault();
onChange?.({ target: { checked: true } });
}
}}
onClick={!disabled && !checked ? () => onChange?.({ target: { checked: true } }) : undefined}
>
{/* 커스텀 라디오 인풋 UI */}
</span>
{label && <span>{label}</span>}
</label>
);
};
스크린 리더 테스트 영상
웹 접근성을 보장하기 위해 스크린 리더 테스트를 진행하였습니다.
아래 영상에서는 체크박스가 스크린 리더에서 올바르게 인식되고 조작될 수 있는지를 확인합니다.
스크린 리더 테스트를 위한 회원가입 폼 코드 (약관 동의 체크박스 그룹)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<fieldset role="group" aria-labelledby="terms-group">
<legend id="terms-group" className="sr-only">
약관 동의
</legend>
{/* 전체 동의 체크박스 */}
<Form.Checkbox
name="agreeToAll"
label={
<Text.Title>
전체동의
</Text.Title>
}
/>
<div>
<Divider isVertical={false} />
{/* 개별 동의 체크박스 */}
<Form.Checkbox
name="age"
aria-describedby="desc-age"
label={
<Text.Body id="desc-age">
만 14세 미만입니다 <Highlight>(필수)</Highlight>
</Text.Body>
}
/>
<Form.Checkbox
name="termsAgreement"
aria-describedby="desc-terms"
label={
<Text.Body id="desc-terms">
서비스 이용약관 동의 <Highlight>(필수)</Highlight>
</Text.Body>
}
/>
<Form.Checkbox
name="userInfo"
aria-describedby="desc-info"
label={
<Text.Body id="desc-info">
개인정보 수집 및 이용 동의 <Highlight>(필수)</Highlight>
</Text.Body>
}
/>
<Form.Checkbox
name="marketingConsent"
aria-describedby="desc-marketing"
label={
<Text.Body id="desc-marketing">
이벤트 등 마케팅 정보 수신 동의 (선택)
</Text.Body>
}
/>
</div>
</fieldset>
성과
- 접근성 향상: 스크린 리더와 키보드로 완벽히 동작하며 모든 사용자에게 동등한 경험 제공
- 디자인 충족: 시안에 맞춘 커스텀 UI를 구현하면서도 기능 손실 없음
- 개발 효율성: 공통 컴포넌트로 재사용성 높아 팀원 간 협업과 생산성 개선
이 프로젝트를 통해 디자인과 접근성을 동시에 고려한 컴포넌트 개발의 중요성을 깨달았고, 단순한 UI 구현을 넘어, 모든 사용자가 동등하게 접근할 수 있는 인터페이스를 만드는 것이 중요하다는 점을 실감했습니다.
This post is licensed under CC BY 4.0 by the author.