React로 Switch 컴포넌트 만들기

Switch의 정의

  • 두 가지 상반된 상태 (On/Off) 중 하나를 선택할 수 있도록 하는 토글형 입력 컴포넌트.
  • 일반적으로 스위치 버튼의 형태로 표시되며, 사용자가 클릭하여 상태를 변경할 수 있다.
switch

Switch의 동작 특성

  • 스위치 버튼 영역을 클릭하면 상태값이 반대로 토글된다.
  • 왼쪽 또는 오른쪽에 스위치에 대한 라벨링 영역을 표시한다.
  • 라벨링 영역을 클릭하면 상태값이 반대로 토글된다.
  • 비활성화된 상태에서는 클릭이 무시된다.

Switch의 유즈 케이스

  • 설정 화면에서 특정 기능의 활성화/비활성화를 제어하는 용도로 사용된다.
chrome-switch

Switch의 구조

스위치의 동작 특성을 만족하기 위해 필요한 메인 컴포넌트는 Switch 이다.

하위 컴포넌트는 SwitchContainer, SwitchTrack, SwitchThumb 로 구성했다.

각각의 컴포넌트에 역할을 부여하여 보자.

  • Switch
    • 스위치 컴포넌트의 메인 컴포넌트 역할.
    • 스위치 상태값, 비활성화 여부, 스위치 상태값을 변경하기 위한 핸들러를 하위 컴포넌트에 전달하는 역할.
  • SwitchContainer
    • 스위치 UI 전체 영역을 감싸는 루트 컨테이너 역할.
    • 스위치 버튼 영역과 라벨링 영역에 대한 클릭 이벤트를 감지하는 역할.
  • SwitchTrack
    • 스위치 알콩이가 좌우로 이동할 수 있는 트랙 영역 역할.
  • SwitchThumb
    • 스위치 알콩이를 표현하는 역할.

구현하기에 앞서

메인 컴포넌트인 Switch 컴포넌트부터 작성하는 것이 아니다.

작은 단위인 하위 컴포넌트부터 작성하고 그것을 조합하여 메인 컴포넌트를 만드는 것이다.

그러므로 SwitchContainer, SwitchTrack, SwitchThumb 컴포넌트를 작성하고 Switch 컴포넌트를 만들어보자.

TIP

스위치 컴포넌트의 형태가 다양하게 만들어질 수 있도록 Compound Component 패턴을 적용한다.

이후의 내용을 잘 따라오기 위해서 꼭 한번 읽고 이해하고 넘어가자.

SwitchContainer 컴포넌트의 마크업과 스타일링

SwitchContainer 컴포넌트를 작성해보자.

children 입력을 받는 button 엘리먼트를 하나 반환할 것이다.

button 엘리먼트를 사용하는 이유는 비활성화 기능 구현시 disabled 속성을 사용할 수 있기 때문이다.

이 속성을 이용하면 요소에서 발생하는 클릭 이벤트 활성화 여부를 간단히 제어할 수 있다.

SwitchContainer.tsx
1import { type PropsWithChildren } from "react";
2
3export default function SwitchContainer({ children }: PropsWithChildren) {
4
5  return (
6    <button
7      className="broccoli-ui-switch-container"
8      role="switch"
9    >
10      {children}
11    </button>
12  );
13}

스위치가 비활성화된 상태를 고려하여 반투명 효과와 커서 모양이 변경되도록 해보았다.

style.css
1.broccoli-ui-switch-container {
2  display: inline-flex;
3  align-items: center;
4  border: none;
5  cursor: pointer;
6  padding: 0;
7  background-color: transparent;
8}
9
10.broccoli-ui-switch-container:disabled {
11  opacity: 0.5;
12  cursor: not-allowed;
13}

SwitchTrack 컴포넌트의 마크업과 스타일링

트랙 영역을 표현하는 SwitchTrack 컴포넌트를 작성하자.

SwitchTrack.tsx
1import { type PropsWithChildren } from "react";
2
3export default function SwitchTrack({ children }: PropsWithChildren) {
4
5  return (
6    <div
7      className="broccoli-ui-switch-track"
8    >
9      {children}
10    </div>
11  );
12}

너비와 높이, 모서리 라운딩 값은 임의의 정적값을 적용했다.

트랙의 배경 색상은 스위치가 On 상태일 때와 Off 상태일 때 다르게 표현되도록 했다.

이후 기능 구현시 스위치 상태값에 따라 클래스가 입력되도록 한다.

style.css
1.broccoli-ui-switch-track {
2  display: flex;
3  width: 40px;
4  height: 22px;
5  border-radius: 20px;
6  padding: 2px;
7}
8
9.broccoli-ui-switch-track--on {
10  background-color: oklch(0.65 0.18 240);
11}
12
13.broccoli-ui-switch-track--off {
14  background-color: oklch(0.803 0 0);
15}

SwitchThumb 컴포넌트의 마크업과 스타일링

스위치 알콩이를 표현하는 SwitchThumb 컴포넌트를 작성하자.

SwitchThumb.tsx
1export default function SwitchThumb() {
2  return <span className="broccoli-ui-switch-thumb"></span>;
3}

스타일은 다음과 같다.

트랙의 좌우 가장자리로 On/Off 상태에 따라 이동하도록 했다.

트랜지션 성능을 고려하여 transform: translateX 를 사용하여 구현했다.

style.css
1.broccoli-ui-switch-thumb {
2  display: inline-block;
3  width: 18px;
4  height: 18px;
5  border-radius: 100%;
6  background-color: oklch(1 0 0);
7  transition: all 0.25s ease-in-out;
8}
9
10.broccoli-ui-switch-thumb--on {
11  transform: translateX(100%);
12}
13
14.broccoli-ui-switch-thumb--off {
15  transform: translateX(0px);
16}

여기까지 작성하였으면 다음처럼 렌더링 될 것이다.

Switch를 하위 컴포넌트의 조합으로 만들기

작성한 하위 컴포넌트를 조합하여 메인 컴포넌트인 Switch를 만들 차례다.

React.Context를 통해 모든 하위 컴포넌트가 같은 프로퍼티 값을 공유하고 있음에 주목하자.

Switch.tsx
1import type { PropsWithChildren } from 'react';
2
3import SwitchContainer from './SwitchContainer.client';
4import SwitchThumb from './SwitchThumb.client';
5import SwitchTrack from './SwitchTrack.client';
6import { SwitchContext } from './context.client';
7
8interface SwitchProps extends PropsWithChildren {
9  classes?: {
10    container?: SwitchClassType; // 스위치 컨테이너 영역 스타일
11    track?: SwitchClassType; // 스위치 트랙 영역 스타일
12    thumb?: SwitchClassType; // 스위치 알콩이 스타일
13  };
14  on: boolean; // 스위치 On/Off 상태
15  disabled?: boolean; // 스위치 비활성화 여부
16  onChange?: (on: boolean) => void; // 스위치를 클릭했을 때 상태값을 변경하기 위한 핸들러
17}
18
19interface SwitchClassType {
20  on?: string;
21  off?: string;
22  disabled?: string;
23}
24
25function Switch({
26  classes,
27  on,
28  disabled = false,
29  children,
30  onChange,
31}: SwitchProps) {
32  return (
33    <SwitchContext value={{ classes, on, disabled, onChange }}>
34      <SwitchContainer>{children}</SwitchContainer>
35    </SwitchContext>
36  );
37}
38
39export default Object.assign(Switch, {
40  Track: SwitchTrack,
41  Thumb: SwitchThumb,
42});

스위치 컴포넌트는 필요한 모든 값을 하위 컴포넌트로 전달할 준비가 되었다.

이후의 단락에서는 한가지 기능씩 구현하여 보자.

Switch의 스타일을 수정하기 용이하게 만들기

스위치 컴포넌트의 스타일 오버라이드를 위해 두가지 방법을 제공할 수 있다.

  • 스타일 시트를 직접 수정하기
  • 클래스 오버라이드

스타일 시트를 수정하는 것은 굳이 설명할 필요가 없을 것 같으니 넘어가자.

클래스 오버라이드가 가능하도록 하위 컴포넌트 구현을 업데이트 해보자.

방법은 React.useContext 훅을 사용하여 부모 컴포넌트에서 전달하는 프로퍼티 값을 참조하는 것이다.

스위치가 On, Off, Disabled 상태일 때 필요한 클래스가 입력되도록 했다.

SwitchContainer.tsx
1import { type PropsWithChildren, useContext } from "react";
2
3import clsx from "clsx";
4
5import { SwitchContext } from "./context.client";
6
7export default function SwitchContainer({ children }: PropsWithChildren) {
8  const { classes, on, disabled } = useContext(SwitchContext);
9
10  return (
11    <button
12      className={clsx("broccoli-ui-switch-container", {
13        [classes?.container?.on ?? ""]: on,
14        [classes?.container?.off ?? ""]: !on,
15        [classes?.container?.disabled ?? ""]: disabled,
16      })}
17      role="switch"
18    >
19      {children}
20    </button>
21  );
22}
SwitchTrack.tsx
1import { type PropsWithChildren, useContext } from "react";
2
3import clsx from "clsx";
4
5import { SwitchContext } from "./context.client";
6
7export default function SwitchTrack({ children }: PropsWithChildren) {
8  const { classes, on, disabled } = useContext(SwitchContext);
9
10  return (
11    <div
12      className={clsx(
13        "broccoli-ui-switch-track",
14        {
15          "broccoli-ui-switch-track--on": on,
16          "broccoli-ui-switch-track--off": !on,
17        },
18        {
19          [classes?.track?.on ?? ""]: on,
20          [classes?.track?.off ?? ""]: !on,
21          [classes?.track?.disabled ?? ""]: disabled,
22        },
23      )}
24    >
25      {children}
26    </div>
27  );
28}
SwitchThumb.tsx
1import { useContext } from 'react';
2
3import clsx from 'clsx';
4
5import { SwitchContext } from './context.client';
6
7export default function SwitchThumb() {
8  const { classes, on, disabled } = useContext(SwitchContext);
9
10  return (
11    <span
12      className={clsx(
13        'broccoli-ui-switch-thumb',
14        {
15          'broccoli-ui-switch-thumb--on': on,
16          'broccoli-ui-switch-thumb--off': !on,
17        },
18        {
19          [classes?.thumb?.on ?? '']: on,
20          [classes?.thumb?.off ?? '']: !on,
21          [classes?.thumb?.disabled ?? '']: disabled,
22        },
23      )}
24    ></span>
25  );
26}

Switch의 아무 부분이나 클릭해도 상태값이 토글되도록 만들기

우리가 SwitchContainer(button)으로 감쌌던 이유가 바로 이 부분이다.

스위치 내의 어떤 영역을 클릭하더라도 ClickEvent Bubbling이 발생하여 결국에는 SwitchContainer가 이 이벤트를 수신하게 된다.

Switch.tsx
1import SwitchContainer from './SwitchContainer.client';
2
3function Switch() {
4  return (
5    <SwitchContext value={{ classes, on, disabled, onChange }}>
6      <SwitchContainer>{children}</SwitchContainer>
7    </SwitchContext>
8  );
9}

그러므로 SwitchContainer 컴포넌트에서 클릭 이벤트 핸들러를 추가하자.

onChange 핸들러를 버튼의 클릭 이벤트 핸들러로 등록하면 된다.

비활성화 상태일때는 간단히 disabled 속성을 부여하여 이벤트 핸들러가 호출되지 않도록 처리한다.

SwitchContainer.tsx
1import { type PropsWithChildren, useContext } from "react";
2
3import clsx from "clsx";
4
5import { SwitchContext } from "./context.client";
6
7export default function SwitchContainer({ children }: PropsWithChildren) {
8  const { classes, on, disabled, onChange } = useContext(SwitchContext);
9
10  const onClick = () => {
11    onChange?.(on);
12  };
13
14  return (
15    <button
16      className={clsx("broccoli-ui-switch-container", {
17        [classes?.container?.on ?? ""]: on,
18        [classes?.container?.off ?? ""]: !on,
19        [classes?.container?.disabled ?? ""]: disabled,
20      })}
21      disabled={disabled}
22      role="switch"
23      aria-checked={on}
24      aria-disabled={disabled}
25      onClick={onClick}
26    >
27      {children}
28    </button>
29  );
30}

여기까지 왔다면 스위치 컴포넌트의 기본적인 기능 구현은 다 된 것이다.

어떻게 사용하죠?

이제 부모 컴포넌트의 관점에서 스위치 컴포넌트를 사용하는 방법을 알아보자.

가장 기본적인 형태이다.

React.useState 훅을 통해 상태를 만들고 스위치 상태값이 제어될 수 있도록 한다.

Page.tsx
1import { useState } from 'react';
2import Switch from './components/Switch';
3
4export default function Page() {
5  const [on, setOn] = useState(false);
6
7  return (
8    <Switch on={on} onChange={() => setOn(!on)}>
9      <Switch.Track>
10        <Switch.Thumb />
11      </Switch.Track>
12    </Switch>
13  );
14}

스위치를 비활성화 하고 싶은 경우이다.

Page.tsx
1import { useState } from 'react';
2import Switch from './components/Switch';
3
4export default function Page() {
5  const [on, setOn] = useState(false);
6
7  return (
8    <Switch on={on} disabled onChange={() => setOn(!on)}>
9      <Switch.Track>
10        <Switch.Thumb />
11      </Switch.Track>
12    </Switch>
13  );
14}

스위치 버튼에 라벨링을 추가하고 싶은 경우이다.

Switch.Track 의 Sibling 엘리먼트로 왼쪽 또는 오른쪽에 라벨링 엘리먼트를 추가하면 된다.

라벨을 클릭해도 상태값이 토글되는 것에 주목하자.

Page.tsx
1import { useState } from 'react';
2import Switch from './components/Switch';
3
4export default function Page() {
5  const [on, setOn] = useState(false);
6
7  return (
8    <Switch on={on} onChange={() => setOn(!on)}>
9      <p style={{ marginRight: '10px' }}>🍎 Left Label</p>
10      <Switch.Track>
11        <Switch.Thumb />
12      </Switch.Track>
13    </Switch>
14  );
15}

이상으로 Switch의 구현 및 활용까지 다루었다.

좀 더 유연한 스타일링에 대한 방법이 있다면 코멘트를 남겨줘도 좋다!