폼 요소 스타일링하기

목차

1. 역사

1995년 HTML2 표준이 form 요소를 도입하였다. 하지만 CSS는 1996년에 나왔고, 나온 이후에도 대부분의 브라우저가 이를 당장 지원하지는 않았다.

그리고 이미 브라우저들에서는 form 요소를 자체적으로 렌더링하고 있었기 때문에 초기에는 form 요소에 CSS를 적용 가능하게 하는 것에 의욕적이지 않았다.

하지만 시간이 지나면서 form 요소들도 몇몇을 제외하고는 대부분 스타일링 가능하게 되었다.

물론 color picker와 같이 CSS만으로는 스타일링하기 힘든 것들도 아직 있다. CSS로 쉽게 스타일링할 수 있는 것부터 시작해서, form 요소들의 스타일링을 정복해보자.

2. 스타일링이 쉬운 요소들

다음과 같은 요소들은 쉽게 스타일링할 수 있다.

<form>, <fieldset>, <legend>, <input>(type="search" 제외), <textarea>, <button>, <label>, <output>

체크박스와 라디오버튼, <input type="search">는 스타일링하려면 약간 복잡한 CSS를 써야 한다. <select>와 일부 input type들은 브라우저마다 매우 다른 기본 스타일을 가지고 있고 어느 정도 스타일링이 가능하지만 원천적으로 스타일링 불가능한 부분들도 있다.

상황에 따라서는 스타일링이 상대적으로 쉬운 다른 컴포넌트들을 이용해서 같은 기능을 구현하는 게 더 나은 선택일 수 있다. 하지만 브라우저별로 생길 약간의 차이를 감수할 수 있다면 크기, 배경 등의 몇 가지 스타일링은 할 수 있다.

쉽게 할 수 있는 요소들은 넘어가고, 어려운 것들만 알아보자. 그리고 우리가 무엇을 할 수 있고 무엇을 할 수 없는지 알아보자.

3. 사전 작업

CSS 폰트 관련 CSS는 어떤 요소에서든 쉽게 사용할 수 있다. 하지만 몇몇 폼 요소에서 font-familyfont-size를 부모로부터 상속하지 않는 브라우저들이 있다. 많은 브라우저가 이 요소들에서 시스템의 기본 폰트를 사용하도록 한다.

따라서 다음처럼 폼 요소들의 스타일을 지정해 준다.

button,
input,
select,
textarea {
  font-family: inherit;
  font-size: 100%;
}
button,
input,
select,
textarea {
  font-family: inherit;
  font-size: 100%;
}
button,
input,
select,
textarea {
  font-family: inherit;
  font-size: 100%;
}
button,
input,
select,
textarea {
  font-family: inherit;
  font-size: 100%;
}

<input type="submit">이 예외적으로 font-family를 상속하지 않는 브라우저가 있다. 이런 부분을 대비하기 위해서는 <button> 태그를 사용하자.

그리고 각 폼 요소들은 각자의 기본적인 테두리, 패딩, 마진 규칙이 있기 때문에 이를 초기화해주는 것도 좋다. 물론 시스템의 기본 스타일을 사용하는 게 좋은지 아니면 커스텀하는 게 좋은지는 많은 토의가 있기 때문에 어느 정도는 개발자의 결정이다.

input,
textarea,
select,
button {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}
input,
textarea,
select,
button {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}
input,
textarea,
select,
button {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}
input,
textarea,
select,
button {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

3.1. appearance

이 CSS는 운영체제에 기반한 UI의 기본 스타일을 적용할지를 결정한다.

appearance: none;
appearance: auto;
appearance: none;
appearance: auto;
appearance: none;
appearance: auto;
appearance: none;
appearance: auto;

보통은 none 값으로만 지정할 것이다. 이렇게 지정하면 시스템의 스타일링을 무력화하고 내가 원하는 스타일링을 적용할 수 있다. 아예 디자인을 백지로 만드는 거라고 생각하면 된다.

4. 몇몇 요소들의 스타일링

4.1. search box 스타일링

검색 박스를 보자.

<input type="search" />
<input type="search" />
<input type="search" />
<input type="search" />

사파리에서는 이러한 검색 박스에 대해 몇 가지 스타일링 제한이 있다. 가령 높이나 글씨 크기를 마음대로 바꿀 수 없다.

이를 해결하기 위해서는 appearance 속성을 none으로 지정해야 한다. 이렇게 하고 나서 스타일링해주면 된다.

input[type="search"] {
  appearance: none;
}
input[type="search"] {
  appearance: none;
}
input[type="search"] {
  appearance: none;
}
input[type="search"] {
  appearance: none;
}

혹은 border나 background CSS를 지정해주는 것도 이런 스타일링 제한 문제를 해결하는 방법이다.

4.2. 체크박스, 라디오버튼 스타일링

체크박스, 라디오버튼의 사이즈는 기본적으로 조절이 안 되도록 되어 있다. 이를 조절하려고 할 시 브라우저에서 해당 요소를 어떻게 렌더링하는지는 브라우저마다 매우 다르다.

조절할 수 있는 건 활성화되었을 때의 색 정도인데, 이는 accent-color CSS 속성을 통해서 조절할 수 있다. 하지만 본격적으로 스타일을 바꾸려고 하면 appearance 속성을 none으로 지정하고 처음부터 스타일링을 해야 한다.

먼저 예시 HTML을 다음과 같이 작성하였다.

<form>
  <fieldset>
    <legend>체크박스와 라디오 버튼</legend>
    <p>
      주문할 케이크 고르기
    </p>
    <input type="checkbox" name="cake" value="choco" id="choco" />
    <label for="choco">초코</label>
    <input type="checkbox" name="cake" value="strawberry" id="strawberry" />
    <label for="strawberry">딸기</label>
    <input type="checkbox" name="cake" value="vanilla" id="vanilla" />
    <label for="vanilla">바닐라</label>

    <p>
      케이크와 함께 주문할 커피 고르기
    </p>

    <input type="radio" name="coffee" value="americano" id="americano" />
    <label for="americano">아메리카노</label>
    <input type="radio" name="coffee" value="latte" id="latte" />
    <label for="latte">라떼</label>
    <input type="radio" name="coffee" value="mocha" id="mocha" />
    <label for="mocha">모카</label>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>체크박스와 라디오 버튼</legend>
    <p>
      주문할 케이크 고르기
    </p>
    <input type="checkbox" name="cake" value="choco" id="choco" />
    <label for="choco">초코</label>
    <input type="checkbox" name="cake" value="strawberry" id="strawberry" />
    <label for="strawberry">딸기</label>
    <input type="checkbox" name="cake" value="vanilla" id="vanilla" />
    <label for="vanilla">바닐라</label>

    <p>
      케이크와 함께 주문할 커피 고르기
    </p>

    <input type="radio" name="coffee" value="americano" id="americano" />
    <label for="americano">아메리카노</label>
    <input type="radio" name="coffee" value="latte" id="latte" />
    <label for="latte">라떼</label>
    <input type="radio" name="coffee" value="mocha" id="mocha" />
    <label for="mocha">모카</label>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>체크박스와 라디오 버튼</legend>
    <p>
      주문할 케이크 고르기
    </p>
    <input type="checkbox" name="cake" value="choco" id="choco" />
    <label for="choco">초코</label>
    <input type="checkbox" name="cake" value="strawberry" id="strawberry" />
    <label for="strawberry">딸기</label>
    <input type="checkbox" name="cake" value="vanilla" id="vanilla" />
    <label for="vanilla">바닐라</label>

    <p>
      케이크와 함께 주문할 커피 고르기
    </p>

    <input type="radio" name="coffee" value="americano" id="americano" />
    <label for="americano">아메리카노</label>
    <input type="radio" name="coffee" value="latte" id="latte" />
    <label for="latte">라떼</label>
    <input type="radio" name="coffee" value="mocha" id="mocha" />
    <label for="mocha">모카</label>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>체크박스와 라디오 버튼</legend>
    <p>
      주문할 케이크 고르기
    </p>
    <input type="checkbox" name="cake" value="choco" id="choco" />
    <label for="choco">초코</label>
    <input type="checkbox" name="cake" value="strawberry" id="strawberry" />
    <label for="strawberry">딸기</label>
    <input type="checkbox" name="cake" value="vanilla" id="vanilla" />
    <label for="vanilla">바닐라</label>

    <p>
      케이크와 함께 주문할 커피 고르기
    </p>

    <input type="radio" name="coffee" value="americano" id="americano" />
    <label for="americano">아메리카노</label>
    <input type="radio" name="coffee" value="latte" id="latte" />
    <label for="latte">라떼</label>
    <input type="radio" name="coffee" value="mocha" id="mocha" />
    <label for="mocha">모카</label>
  </fieldset>
</form>

기본적으로 appearance 속성을 none으로 지정.

input[type="checkbox"],
input[type="radio"] {
  appearance: none;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
}

이러면 체크박스 혹은 라디오버튼이 있어야 할 자리에 아무것도 뜨지 않는다. 이제 한번 스타일링을 해보자.

체크박스의 경우 체크가 된 박스에 체크 표시를, 라디오버튼의 경우 선택된 항목에 원을 그린다. 이를 구현하면 된다.

여러가지 방법이 있겠지만, ::before를 사용하여 요소를 하나 만들고, 여기의 content에 유니코드를 넣어서 체크 여부에 따라 표시되고 아니고를 결정하도록 하였다.

레이아웃이 다시 계산되는 것을 막기 위해서 display:none 대신 visibility: hidden을 사용하였다. 다음과 같이 CSS를 작성한다. CSS를 깔끔하게 작성한다는 면에서도, 디자인 면에서도 그렇게 잘 짜인 코드는 아니다. 하지만 요점은 이런 식으로 체크박스와 라디오버튼을 기초부터 스타일링이 가능하다는 것이다.

input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid #ccc;
  cursor: pointer;
  vertical-align: -2px;
  color:violet;
}

input[type="checkbox"]::before,
input[type="radio"]::before {
  position: absolute;
  font-size: 1.2rem;
  right: 1px;
  top: -10px;
  visibility: hidden;
}

input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before {
  visibility: visible;
}

input[type="checkbox"]{
  border-radius: 5px;
}

input[type="radio"]{
  border-radius: 50%;
}

input[type="checkbox"]::before {
  content: "✔";
  top: -2px;
}

input[type="radio"]::before {
  content: "●";
  font-size: 2rem;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid #ccc;
  cursor: pointer;
  vertical-align: -2px;
  color:violet;
}

input[type="checkbox"]::before,
input[type="radio"]::before {
  position: absolute;
  font-size: 1.2rem;
  right: 1px;
  top: -10px;
  visibility: hidden;
}

input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before {
  visibility: visible;
}

input[type="checkbox"]{
  border-radius: 5px;
}

input[type="radio"]{
  border-radius: 50%;
}

input[type="checkbox"]::before {
  content: "✔";
  top: -2px;
}

input[type="radio"]::before {
  content: "●";
  font-size: 2rem;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid #ccc;
  cursor: pointer;
  vertical-align: -2px;
  color:violet;
}

input[type="checkbox"]::before,
input[type="radio"]::before {
  position: absolute;
  font-size: 1.2rem;
  right: 1px;
  top: -10px;
  visibility: hidden;
}

input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before {
  visibility: visible;
}

input[type="checkbox"]{
  border-radius: 5px;
}

input[type="radio"]{
  border-radius: 50%;
}

input[type="checkbox"]::before {
  content: "✔";
  top: -2px;
}

input[type="radio"]::before {
  content: "●";
  font-size: 2rem;
}
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid #ccc;
  cursor: pointer;
  vertical-align: -2px;
  color:violet;
}

input[type="checkbox"]::before,
input[type="radio"]::before {
  position: absolute;
  font-size: 1.2rem;
  right: 1px;
  top: -10px;
  visibility: hidden;
}

input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before {
  visibility: visible;
}

input[type="checkbox"]{
  border-radius: 5px;
}

input[type="radio"]{
  border-radius: 50%;
}

input[type="checkbox"]::before {
  content: "✔";
  top: -2px;
}

input[type="radio"]::before {
  content: "●";
  font-size: 2rem;
}

이렇게 스타일링한 결과는 다음과 같다.

체크박스와 라디오버튼 스타일링 결과

4.3. select

select의 스타일링에 문제되는 부분은 2가지가 있다. 이를 알아보기 위해서 먼저 커피를 고르는 select 요소를 한번 만들어 보자.

<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <select id="coffeeSelection">
      <option value="americano">아메리카노</option>
      <option value="latte">라떼</option>
      <option value="mocha">모카</option>
    </select>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <select id="coffeeSelection">
      <option value="americano">아메리카노</option>
      <option value="latte">라떼</option>
      <option value="mocha">모카</option>
    </select>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <select id="coffeeSelection">
      <option value="americano">아메리카노</option>
      <option value="latte">라떼</option>
      <option value="mocha">모카</option>
    </select>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <select id="coffeeSelection">
      <option value="americano">아메리카노</option>
      <option value="latte">라떼</option>
      <option value="mocha">모카</option>
    </select>
</form>

첫째는 select가 드롭다운으로 작동함을 나타내는 화살표를 스타일링하는 부분이다. 이 화살표는 브라우저마다 다르며 select 박스의 크기가 변할 때마다 바뀌거나 이상하게 리사이징될 수 있다.

이 문제는 appearance: none으로 기본 화살표를 없앤 후 새로 만드는 것으로 어느 정도 해결할 수 있다. 하지만 따로 화살표 아이콘을 사용하고 싶다든가, 화살표 영역까지 클릭하도록 하는 등의 조작이 필요하다면 순수 CSS로는 무리이며 JS를 사용하거나 select에 해당하는 요소를 직접 제작해야 한다.

우리가 할 수 있는 것을 해보자. 일단 아이콘을 없애기 위해 appearance: none을 지정한다. 그러면 화살표 아이콘과 마진 등이 사라진다.

그다음 직접 아이콘을 만들자. 이를 위해서 ::before::after를 사용할 것인데 그러려면 div 등의 태그로 select의 래퍼를 만들어 주어야 한다.

이는 ::after와 같은 요소들은 요소의 포매팅 박스에 상대적으로 배치되는데 select는 replaced element처럼 작동하여 document style이 아니라 브라우저에 의해서 배치되고 따라서 이러한 포매팅 박스를 가지고 있지 않기 때문이다.

래퍼를 만들어 주고 여기에 ::after를 적용해서 스타일링하자.

<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <div class="select-wrapper">
      <select class="select" id="coffeeSelection">
        <option value="americano">아메리카노</option>
        <option value="latte">라떼</option>
        <option value="mocha">모카</option>
      </select>
    </div>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <div class="select-wrapper">
      <select class="select" id="coffeeSelection">
        <option value="americano">아메리카노</option>
        <option value="latte">라떼</option>
        <option value="mocha">모카</option>
      </select>
    </div>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <div class="select-wrapper">
      <select class="select" id="coffeeSelection">
        <option value="americano">아메리카노</option>
        <option value="latte">라떼</option>
        <option value="mocha">모카</option>
      </select>
    </div>
</form>
<form>
  <fieldset>
    <legend>Select</legend>
    <label for="coffeeSelection">커피 고르기</label>
    <div class="select-wrapper">
      <select class="select" id="coffeeSelection">
        <option value="americano">아메리카노</option>
        <option value="latte">라떼</option>
        <option value="mocha">모카</option>
      </select>
    </div>
</form>
select{
  appearance:none;
  width:100%;
  height:100%;
}

.select-wrapper{
  position:relative;
  width:100px;
  height:30px;
}

.select-wrapper::after{
  content: "▼";
  font-size: 1rem;
  top: 6px;
  right: 10px;
  position: absolute;
  color: violet;
}
select{
  appearance:none;
  width:100%;
  height:100%;
}

.select-wrapper{
  position:relative;
  width:100px;
  height:30px;
}

.select-wrapper::after{
  content: "▼";
  font-size: 1rem;
  top: 6px;
  right: 10px;
  position: absolute;
  color: violet;
}
select{
  appearance:none;
  width:100%;
  height:100%;
}

.select-wrapper{
  position:relative;
  width:100px;
  height:30px;
}

.select-wrapper::after{
  content: "▼";
  font-size: 1rem;
  top: 6px;
  right: 10px;
  position: absolute;
  color: violet;
}
select{
  appearance:none;
  width:100%;
  height:100%;
}

.select-wrapper{
  position:relative;
  width:100px;
  height:30px;
}

.select-wrapper::after{
  content: "▼";
  font-size: 1rem;
  top: 6px;
  right: 10px;
  position: absolute;
  color: violet;
}

이러면 아래 방향의 삼각형 화살표가 보라색으로 새로 생긴다.

두번째 문제는 select를 눌렀을 때 나오는, option들이 들어간 박스를 커스텀할 수 없다는 문제이다. 부모로부터 폰트는 상속하도록 할 수 있지만 간격이나 글씨 색상 등을 조절할 수는 없다. 참고로 이는 <datalist> 태그에 대해서도 마찬가지다.

이 부분은 <select> 요소에서 해결할 수 없다. 이 부분을 해결하고 싶다면 커스텀 select를 지원하는 라이브러리를 쓰거나, 선택 상자를 직접 만들어야 한다.

4.4. file input

<form>
  <fieldset>
    <legend>File Input</legend>
    <label class="fileInputLabel" for="fileInput">파일 고르기</label>
    <input type="file" id="fileInput" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>File Input</legend>
    <label class="fileInputLabel" for="fileInput">파일 고르기</label>
    <input type="file" id="fileInput" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>File Input</legend>
    <label class="fileInputLabel" for="fileInput">파일 고르기</label>
    <input type="file" id="fileInput" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>File Input</legend>
    <label class="fileInputLabel" for="fileInput">파일 고르기</label>
    <input type="file" id="fileInput" />
  </fieldset>
</form>

file input의 문제는 파일 탐색기를 여는 버튼이 완전히 스타일링 불가능하다는 것이다. 사이즈 조절이나 색, 폰트조차 변경이 불가능하다.

따라서 이를 스타일링하기 위해서는 input의 label도 input과 연관되어 작동한다는 것을 이용하자. input에 대응되는 label을 스타일링하고, input을 숨겨버리자.

label에 class를 준 것에 주의하자.

input[type="file"]{
  display: none;
}

.fileInputLabel{
  box-shadow: 1px 1px 3px #ccc;
  border: 1px solid #ccc;
  border-radius: 3px;
  text-align: center;
  line-height: 1.5;
  padding: 10px 20px;
}

.fileInputLabel:hover{
  cursor: pointer;
  background-color: #eee;
}
input[type="file"]{
  display: none;
}

.fileInputLabel{
  box-shadow: 1px 1px 3px #ccc;
  border: 1px solid #ccc;
  border-radius: 3px;
  text-align: center;
  line-height: 1.5;
  padding: 10px 20px;
}

.fileInputLabel:hover{
  cursor: pointer;
  background-color: #eee;
}
input[type="file"]{
  display: none;
}

.fileInputLabel{
  box-shadow: 1px 1px 3px #ccc;
  border: 1px solid #ccc;
  border-radius: 3px;
  text-align: center;
  line-height: 1.5;
  padding: 10px 20px;
}

.fileInputLabel:hover{
  cursor: pointer;
  background-color: #eee;
}
input[type="file"]{
  display: none;
}

.fileInputLabel{
  box-shadow: 1px 1px 3px #ccc;
  border: 1px solid #ccc;
  border-radius: 3px;
  text-align: center;
  line-height: 1.5;
  padding: 10px 20px;
}

.fileInputLabel:hover{
  cursor: pointer;
  background-color: #eee;
}

이러면 못생긴 파일 올리기 버튼이 아니라 파일 고르기라고 쓰인 흰색 버튼이 나오고 그걸 눌렀을 때 파일 탐색기가 뜨는 것을 볼 수 있다.

4.5. range input

range input의 bar를 스타일링하는 건 쉬운 일이지만 handle을 스타일링하는 건 매우 어렵다. 다음과 같은 HTML을 먼저 보자.

<form>
  <fieldset>
    <legend>Range Input</legend>
    <label class="rangeLabel" for="range">범위 입력기</label>
    <input type="range" id="range" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>Range Input</legend>
    <label class="rangeLabel" for="range">범위 입력기</label>
    <input type="range" id="range" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>Range Input</legend>
    <label class="rangeLabel" for="range">범위 입력기</label>
    <input type="range" id="range" />
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>Range Input</legend>
    <label class="rangeLabel" for="range">범위 입력기</label>
    <input type="range" id="range" />
  </fieldset>
</form>

그리고 bar는 다음과 같이 스타일링할 수 있다.

input[type="range"]{
  appearance: none;
  background: violet;
  height:3px;
  padding: 0;
  border:1px solid transparent;
}
input[type="range"]{
  appearance: none;
  background: violet;
  height:3px;
  padding: 0;
  border:1px solid transparent;
}
input[type="range"]{
  appearance: none;
  background: violet;
  height:3px;
  padding: 0;
  border:1px solid transparent;
}
input[type="range"]{
  appearance: none;
  background: violet;
  height:3px;
  padding: 0;
  border:1px solid transparent;
}

이렇게 하면 범위 입력은 보라색 bar에서 이루어지게 된다. handle을 스타일링하려면 ::-webkit-slider-thumb 과 같은 브라우저에서 자체적으로 지원하는 의사 요소를 사용해야 한다.

이다음 CSS는 Styling Cross-Browser Compatible Range Inputs with CSS를 참고하여 작성되었다.

/* range input bar
크로스 브라우징을 위해서는
::webkit-slider-runnable-track
::-moz-range-track
::-ms-track 등에도 같은 속성 적용
*/
input[type="range"]{
  appearance: none;
  background: red;
  height: 2px;
  padding:0;
  border:1px solid transparent;
}

/* range input의 thumb(handle) - Webkit */
/*
firefox는 ::-moz-range-thumb
IE는 ::-ms-thumb
*/
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  border:none;
  height: 20px;
  width: 15px;
  border-radius: 3px;
  background: palevioletred;
  cursor: pointer;
  box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d;
}
/* range input bar
크로스 브라우징을 위해서는
::webkit-slider-runnable-track
::-moz-range-track
::-ms-track 등에도 같은 속성 적용
*/
input[type="range"]{
  appearance: none;
  background: red;
  height: 2px;
  padding:0;
  border:1px solid transparent;
}

/* range input의 thumb(handle) - Webkit */
/*
firefox는 ::-moz-range-thumb
IE는 ::-ms-thumb
*/
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  border:none;
  height: 20px;
  width: 15px;
  border-radius: 3px;
  background: palevioletred;
  cursor: pointer;
  box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d;
}
/* range input bar
크로스 브라우징을 위해서는
::webkit-slider-runnable-track
::-moz-range-track
::-ms-track 등에도 같은 속성 적용
*/
input[type="range"]{
  appearance: none;
  background: red;
  height: 2px;
  padding:0;
  border:1px solid transparent;
}

/* range input의 thumb(handle) - Webkit */
/*
firefox는 ::-moz-range-thumb
IE는 ::-ms-thumb
*/
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  border:none;
  height: 20px;
  width: 15px;
  border-radius: 3px;
  background: palevioletred;
  cursor: pointer;
  box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d;
}
/* range input bar
크로스 브라우징을 위해서는
::webkit-slider-runnable-track
::-moz-range-track
::-ms-track 등에도 같은 속성 적용
*/
input[type="range"]{
  appearance: none;
  background: red;
  height: 2px;
  padding:0;
  border:1px solid transparent;
}

/* range input의 thumb(handle) - Webkit */
/*
firefox는 ::-moz-range-thumb
IE는 ::-ms-thumb
*/
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  border:none;
  height: 20px;
  width: 15px;
  border-radius: 3px;
  background: palevioletred;
  cursor: pointer;
  box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d;
}

5. 스타일링이 불가능한 요소들

5.1. date input

input 태그의 type="datetime-local", type="time", type="week", type="month"와 같이 날짜와 시간을 입력하는 input들은 다른 input과 같이 기본 input box 스타일링은 쉽다. 박스의 크기, 색깔 등 말이다.

하지만 input을 클릭시 나오는 datepicker, timepicker는 아예 스타일링을 따로 할 수 없고, 브라우저마다 기본 스타일링이 조금씩 다르며 appearance:none으로 스타일링을 제거할 수도 없다.

따라서 picker 부분을 스타일링하고 싶다면 직접 이를 만들어야 한다.

5.2. number input

number input은 spinner를 기본적으로 제공하고, 이는 위의 date input과 같은 문제로 스타일링이 불가능하다.

하지만 데이터가 숫자로 제한되는 비슷한 input인 type="tel"을 사용하면 된다. 이를 쓰면 같은 text input을 제공하면서 숫자로 데이터를 제한하고, 모바일 디바이스에서 숫자 키패드를 제공한다.

5.3. color input

border, padding 등은 없앨 수 있지만 color picker는 원천적으로 스타일링 불가능하다.

5.4. meter, progress

이 요소들은 잘 쓰이지도 않으면서 스타일링은 끔찍하게 어렵다. 이걸 스타일링하는 것보다는 직접 비슷한 요소를 만드는 게 좋은 선택이다.

이런 요소들을 위해 커스텀 요소를 직접 만드는 법을 이후에 다룰 것이다.

6. 의사 클래스 셀렉터를 이용한 스타일링

CSS를 다뤄보았다면 :hover, :focus와 같은 의사 클래스 셀렉터에 대해서는 이미 알고 있을 것이다. 그러나 폼 요소들에 쓰이는 다른 의사 클래스 셀렉터들도 있다. 이들을 사용례를 통해서 알아보자.

6.1. 필수 제출 요소 나타내기

회원가입 요소를 만든다고 가정하자. 이때 필수로 제출해야 하는 요소들이 있다. 이를 나타내기 위해서는 required 속성을 사용한다.

<form>
  <fieldset>
    <legend>회원 가입</legend>
      <div>
        <label for="email">Email</label>
        <input type="email" id="email" required/>
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" id="password" required/>
      </div>
      <div>
        <label for="password-check">Password 확인</label>
        <input type="password" id="password-check" required />
      </div>
      <div>
        <label for="name">이름</label>
        <input type="text" id="name" />
      </div>

      <div>
        <label for="birth">생년월일</label>
        <input type="date" id="birth" />
      </div>
      
      <button type="submit">회원가입</button>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>회원 가입</legend>
      <div>
        <label for="email">Email</label>
        <input type="email" id="email" required/>
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" id="password" required/>
      </div>
      <div>
        <label for="password-check">Password 확인</label>
        <input type="password" id="password-check" required />
      </div>
      <div>
        <label for="name">이름</label>
        <input type="text" id="name" />
      </div>

      <div>
        <label for="birth">생년월일</label>
        <input type="date" id="birth" />
      </div>
      
      <button type="submit">회원가입</button>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>회원 가입</legend>
      <div>
        <label for="email">Email</label>
        <input type="email" id="email" required/>
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" id="password" required/>
      </div>
      <div>
        <label for="password-check">Password 확인</label>
        <input type="password" id="password-check" required />
      </div>
      <div>
        <label for="name">이름</label>
        <input type="text" id="name" />
      </div>

      <div>
        <label for="birth">생년월일</label>
        <input type="date" id="birth" />
      </div>
      
      <button type="submit">회원가입</button>
  </fieldset>
</form>
<form>
  <fieldset>
    <legend>회원 가입</legend>
      <div>
        <label for="email">Email</label>
        <input type="email" id="email" required/>
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" id="password" required/>
      </div>
      <div>
        <label for="password-check">Password 확인</label>
        <input type="password" id="password-check" required />
      </div>
      <div>
        <label for="name">이름</label>
        <input type="text" id="name" />
      </div>

      <div>
        <label for="birth">생년월일</label>
        <input type="date" id="birth" />
      </div>
      
      <button type="submit">회원가입</button>
  </fieldset>
</form>

이러면 :required:optional 의사 클래스 셀렉터를 사용할 수 있다. 이를 통해서 필수 제출 요소와 선택 제출 요소를 구분할 수 있다.

input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}

이렇게 하면 필수 제출 요소는 초록색 테두리가 생기고, 선택 제출 요소는 빨간색 테두리가 생긴다. 하지만 이렇게 하면 접근성도 떨어지고, 필수 제출 요소를 나타내는 일반적인 컨벤션은 *을 사용하거나 required라는 텍스트를 붙이는 것임을 생각할 때 그렇게 좋은 방식도 아니다.

따라서 ::after 의사 요소를 이용하여 필수 제출 요소를 나타내는 것이 좋다.

일단 ::after의사 요소는 요소의 box에 상대적으로 배치되는데 input 요소도 replaced element에 가깝게 동작하기 때문에 box가 없고 따라서 ::after를 제대로 사용하기 위한 span을 더해주자. 이를테면 다음과 같이.

<div>
  <label for="email">Email</label>
  <input type="email" id="email" required/>
  <span></span>
</div>
<div>
  <label for="email">Email</label>
  <input type="email" id="email" required/>
  <span></span>
</div>
<div>
  <label for="email">Email</label>
  <input type="email" id="email" required/>
  <span></span>
</div>
<div>
  <label for="email">Email</label>
  <input type="email" id="email" required/>
  <span></span>
</div>

그리고 다음과 같이 필수 제출 요소의 뒤에 오는 span 요소에 ::after를 사용하여 required문구를 추가할 수 있다. 배치에는 position: absolute를 사용하였다.

input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}

input + span {
  position: relative;
}

input:required + span::after {
  font-size: 0.7rem;
  position: absolute;
  content: "required";
  color: white;
  background-color: black;
  padding: 2px 10px;
  left: 10px;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}

input + span {
  position: relative;
}

input:required + span::after {
  font-size: 0.7rem;
  position: absolute;
  content: "required";
  color: white;
  background-color: black;
  padding: 2px 10px;
  left: 10px;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}

input + span {
  position: relative;
}

input:required + span::after {
  font-size: 0.7rem;
  position: absolute;
  content: "required";
  color: white;
  background-color: black;
  padding: 2px 10px;
  left: 10px;
}
input:required{
    border: 1px solid green;
}

input:optional{
    border: 1px solid red;
}

input + span {
  position: relative;
}

input:required + span::after {
  font-size: 0.7rem;
  position: absolute;
  content: "required";
  color: white;
  background-color: black;
  padding: 2px 10px;
  left: 10px;
}

이러면 좀 못생긴 required라는 텍스트가 필수 제출 요소 뒤에 나타나게 된다.

6.2. 데이터 유효성 스타일링

https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation

폼 요소의 데이터가 유효한지에 따라 스타일링하는 것도 자주 쓰인다. HTML5에서 도입된 새로운 input type들로 인해 유효성 검증도 좀더 쉬워졌다.

예를 들어서 <input type="email">의 경우 이메일 형식이 아니면 데이터가 유효하지 않다고 판단할 수 있다. 이런 경우 :valid:invalid 의사 클래스 셀렉터를 사용할 수 있다.

이 역시 다른 셀렉터들과 같이 사용할 수 있는데, 예를 들어 ::after를 이용해서 현재 유효 상태를 나타낼 수 있다. 이를 제대로 쓰기 위해서 위에서 했던 것처럼 빈 <span>태그를 사용했다.

input + span {
  position: relative;
}

input + span::before{
  position: absolute;
  right: -20px;
}

input:invalid{
  border: 1px solid red;
}

input:invalid + span::before{
  content: "✖";
  color: red;
}

input:valid + span::before {
  content: "✓";
  color: green;
}
input + span {
  position: relative;
}

input + span::before{
  position: absolute;
  right: -20px;
}

input:invalid{
  border: 1px solid red;
}

input:invalid + span::before{
  content: "✖";
  color: red;
}

input:valid + span::before {
  content: "✓";
  color: green;
}
input + span {
  position: relative;
}

input + span::before{
  position: absolute;
  right: -20px;
}

input:invalid{
  border: 1px solid red;
}

input:invalid + span::before{
  content: "✖";
  color: red;
}

input:valid + span::before {
  content: "✓";
  color: green;
}
input + span {
  position: relative;
}

input + span::before{
  position: absolute;
  right: -20px;
}

input:invalid{
  border: 1px solid red;
}

input:invalid + span::before{
  content: "✖";
  color: red;
}

input:valid + span::before {
  content: "✓";
  color: green;
}

비슷한 의사 클래스로 :in-range, :out-of-range가 있다. min, max 속성을 갖는 numeric input에서 유효성 검사에 따른 스타일링에 쓸 수 있다. :valid와 비슷하게 쓰이지만 사용자에게 '유효하지 않은 숫자'라는 것보다 더 많은 정보를 제공해서 사용자 경험을 향상시키고 싶을 때 쓸 수 있다.

6.3. 폼 요소의 상태에 따른 스타일링

활성화 상태인 폼 요소에만, 혹은 비활성화 상태인 폼 요소에만 스타일링을 할 때 :enabled:disabled 의사 클래스 셀렉터를 사용할 수 있다.

예를 들어서 이미 제출된 정보에 대해서 요소를 disabled 처리한 후 :disabled 셀렉터로 비활성화된 요소를 스타일링할 수 있다.

비슷하지만 다른 용도로 :read-only 셀렉터가 있다. 사용자가 편집할 수는 없지만 폼이 제출될 때 함께 제출되기는 하는 요소를 스타일링한다. 반대되는 의사 클래스 셀렉터로 :read-write가 있다.

폼 요소 중에 disabledreadonly를 설정할 수 있는 것들이 있는데 이들에 쓴다. 기본값에 해당하는 :enabled:read-write는 잘 사용되지 않는 셀렉터이다.

6.4. 기타 의사 클래스 셀렉터

다음 의사 클래스 셀렉터들은 유용할 수도 있지만 브라우저 지원이 제대로 되지 않을 수 있다.

focus상태인 요소가 내부에 있는지를 판단하는 :focus-within, 키보드 조작을 통해서 focus된 요소를 판단하는 :focus-visible 등의 의사 클래스 셀렉터도 있다.

:placeholder-shown은 placeholder가 보이는지를 판단하는 의사 클래스 셀렉터이다. 이를 이용해서 placeholder가 보이는 동안은 다른 스타일을 적용할 수 있다.

자식이 없는 요소를 선택하는 :empty도 있다.

7. 요소 커스텀하기

기존의 폼 요소들이 부족하게 느껴질 때가 있을 수 있다. 혹은 나만의 어떤 양식 요소를 만들고 싶을 수도 있고. 이럴 때는 커스텀 요소를 만들어서 사용할 수 있다. 단 모든 부분에서 적절하게 동작하는 양식 요소를 직접 만드는 것은 매우 번거롭고 생각할 게 많은 작업이므로 웬만하면 기존의 요소를 활용하거나 서드파티 라이브러리를 사용하는 게 좋다.

하지만 해야 할 때가 있을 수 있으므로 여기서는 스타일링이 정말 힘든 요소 중 하나인 <select>를 커스텀해 보면서 어떻게 커스텀 요소를 만들고 사용하는지 알아보자.

이를 위해서는 기존의 폼 요소가 어떻게 동작하는지를 살펴보는 게 큰 도움과 참고가 된다는 걸 기억하자.

https://developer.mozilla.org/en-US/docs/Learn/Forms/How_to_build_custom_form_controls#design_structure_and_semantics

7.1. 분석

기존 <select> 요소는 마우스 혹은 키보드로 사용할 수 있어야 하고 스크린 리더와도 호환 가능해야 한다. 이를 조건으로 <select>의 동작에 대한 분석을 해보자.

<select>는 다음과 같은 경우 normal state이다.

  • 처음 로딩될 때
  • active였다가 사용자가 외부를 클릭했을 때
  • active였다가 사용자가 키보드로 다른 곳을 focus했을 때

<select>는 다음과 같은 경우 active state이다.

  • 사용자가 요소를 클릭했거나 터치스크린에서 터치했을 때
  • 사용자가 키보드로 요소를 focus했을 때
  • 요소가 open 상태였다가 사용자가 클릭했을 때

<select>는 다음과 같은 경우 open state이다.

  • 다른 어떤 상태에 있다가 사용자가 클릭했을 때

그리고 언제 요소의 선택값이 바뀌는지도 분석해야 한다. 물론 UI적으로 들어가면 위/아래 방향키 반응 등 분석할 게 수도 없겠지만...일단은 분석이 중요하다는 것 정도만 보고 넘어가자.

만약 정말 새로운 요소를 만들어야 한다면 모든 경우에 대한 대응과 분석이 정말 중요해진다. 새로운 요소를 만드는 건 정말 쉽지 않다! 이왕이면 새로운 상호작용 요소를 만들지 말자.

아무튼 이대로 한번 커스텀 select를 만들어보자. 하지만 이게 그대로 사용될 수 있는 코드는 아니고 그냥 시범용이다.

7.2. HTML 구조

다음과 같이 기초 구조를 잡고 클래스명으로 각각의 역할을 나타냈다. 키보드 접근을 위해 tabindex, 접근성을 위해 role속성을 부여하였다. <div>에는 role을 가진 각 자식 요소를 묶는 역할을 뜻하는 listbox를 부여했고 <ul>의 list role을 덮어씌우기 위해서는 특별한 의미는 없고 정보를 보여주는 데에 쓰인다는 의미의 presentation role을 부여했다.

<h1>메뉴를 골라보자</h1>
<div class="select" tabindex="0" role="listbox">
  <span class="value">아메리카노</span>
  <ul class="option-list hidden" role="presentation">
    <li role="option" class="option">아메리카노</li>
    <li role="option" class="option">카페라떼</li>
    <li role="option" class="option">카페모카</li>
    <li role="option" class="option">카푸치노</li>
    <li role="option" class="option">바닐라라떼</li>
    <li role="option" class="option">헤이즐넛라떼</li>
    <li role="option" class="option">카라멜마끼아또</li>
  </ul>
</div>
<h1>메뉴를 골라보자</h1>
<div class="select" tabindex="0" role="listbox">
  <span class="value">아메리카노</span>
  <ul class="option-list hidden" role="presentation">
    <li role="option" class="option">아메리카노</li>
    <li role="option" class="option">카페라떼</li>
    <li role="option" class="option">카페모카</li>
    <li role="option" class="option">카푸치노</li>
    <li role="option" class="option">바닐라라떼</li>
    <li role="option" class="option">헤이즐넛라떼</li>
    <li role="option" class="option">카라멜마끼아또</li>
  </ul>
</div>
<h1>메뉴를 골라보자</h1>
<div class="select" tabindex="0" role="listbox">
  <span class="value">아메리카노</span>
  <ul class="option-list hidden" role="presentation">
    <li role="option" class="option">아메리카노</li>
    <li role="option" class="option">카페라떼</li>
    <li role="option" class="option">카페모카</li>
    <li role="option" class="option">카푸치노</li>
    <li role="option" class="option">바닐라라떼</li>
    <li role="option" class="option">헤이즐넛라떼</li>
    <li role="option" class="option">카라멜마끼아또</li>
  </ul>
</div>
<h1>메뉴를 골라보자</h1>
<div class="select" tabindex="0" role="listbox">
  <span class="value">아메리카노</span>
  <ul class="option-list hidden" role="presentation">
    <li role="option" class="option">아메리카노</li>
    <li role="option" class="option">카페라떼</li>
    <li role="option" class="option">카페모카</li>
    <li role="option" class="option">카푸치노</li>
    <li role="option" class="option">바닐라라떼</li>
    <li role="option" class="option">헤이즐넛라떼</li>
    <li role="option" class="option">카라멜마끼아또</li>
  </ul>
</div>

7.3. CSS

select와 같은 작동을 위해서 다음과 같은 CSS를 작성한다. .select::after를 보면 select 요소의 아래 화살표를 ::after선택자의 content 속성을 이용해 표현한 것을 볼 수 있다.

.select{
  position:relative;
  /* 요소가 text flow의 일부가 되고 크기 조절도 가능하도록 */
  display:inline-block;
}

.select.active,
.select:focus{
  outline:none;
  box-shadow:0 0 3px 1px #227755;
}

.select .option-list{
  position:absolute;
  top:100%;
  left:0;
}

/* hidden이 되면 높이를 0으로 만들고 숨긴다 */
.select .option-list.hidden{
  max-height:0;
  visibility:hidden;
}

/* 여기부터는 장식 CSS */
.select{
  font-size:1rem;
  box-sizing:border-box;
  padding:0.5rem 1rem;

  width:10rem;

  border:1px solid #227755;
  border-radius:4px;
  box-shadow:0 0 3px 1px #227755;

  background-color:#fff;
}

.select .value{
  display:inline-block;
  width:100%;
  overflow:hidden;
  
  white-space:nowrap;
  text-overflow:ellipsis;
  vertical-align:top;
}

.select::after{
  content: "▼";
  position:absolute;
  top:0;
  right:0;
  z-index:1;

  box-sizing:border-box;

  height:100%;
  width:2rem;
  padding-top:0.3rem;

  border-left:1px solid #227755;
  border-radius:0 4px 4px 0;

  background-color:#000;
  color:#fff;
  text-align:center;
}

.select .option-list{
  z-index:2;

  list-style:none;
  margin:0;
  padding:0;

  box-sizing:border-box;

  min-width:100%;

  max-height:10rem;
  overflow-y:auto;
  overflow-x:hidden;

  border:1px solid #227755;
  box-shadow:0 0 3px 1px #227755;
  background-color:#fff;
}

.select .option{
  padding:0.5rem 1rem;
}

.select .highlight{
  background-color:#227755;
  color:#fff;
}
.select{
  position:relative;
  /* 요소가 text flow의 일부가 되고 크기 조절도 가능하도록 */
  display:inline-block;
}

.select.active,
.select:focus{
  outline:none;
  box-shadow:0 0 3px 1px #227755;
}

.select .option-list{
  position:absolute;
  top:100%;
  left:0;
}

/* hidden이 되면 높이를 0으로 만들고 숨긴다 */
.select .option-list.hidden{
  max-height:0;
  visibility:hidden;
}

/* 여기부터는 장식 CSS */
.select{
  font-size:1rem;
  box-sizing:border-box;
  padding:0.5rem 1rem;

  width:10rem;

  border:1px solid #227755;
  border-radius:4px;
  box-shadow:0 0 3px 1px #227755;

  background-color:#fff;
}

.select .value{
  display:inline-block;
  width:100%;
  overflow:hidden;
  
  white-space:nowrap;
  text-overflow:ellipsis;
  vertical-align:top;
}

.select::after{
  content: "▼";
  position:absolute;
  top:0;
  right:0;
  z-index:1;

  box-sizing:border-box;

  height:100%;
  width:2rem;
  padding-top:0.3rem;

  border-left:1px solid #227755;
  border-radius:0 4px 4px 0;

  background-color:#000;
  color:#fff;
  text-align:center;
}

.select .option-list{
  z-index:2;

  list-style:none;
  margin:0;
  padding:0;

  box-sizing:border-box;

  min-width:100%;

  max-height:10rem;
  overflow-y:auto;
  overflow-x:hidden;

  border:1px solid #227755;
  box-shadow:0 0 3px 1px #227755;
  background-color:#fff;
}

.select .option{
  padding:0.5rem 1rem;
}

.select .highlight{
  background-color:#227755;
  color:#fff;
}
.select{
  position:relative;
  /* 요소가 text flow의 일부가 되고 크기 조절도 가능하도록 */
  display:inline-block;
}

.select.active,
.select:focus{
  outline:none;
  box-shadow:0 0 3px 1px #227755;
}

.select .option-list{
  position:absolute;
  top:100%;
  left:0;
}

/* hidden이 되면 높이를 0으로 만들고 숨긴다 */
.select .option-list.hidden{
  max-height:0;
  visibility:hidden;
}

/* 여기부터는 장식 CSS */
.select{
  font-size:1rem;
  box-sizing:border-box;
  padding:0.5rem 1rem;

  width:10rem;

  border:1px solid #227755;
  border-radius:4px;
  box-shadow:0 0 3px 1px #227755;

  background-color:#fff;
}

.select .value{
  display:inline-block;
  width:100%;
  overflow:hidden;
  
  white-space:nowrap;
  text-overflow:ellipsis;
  vertical-align:top;
}

.select::after{
  content: "▼";
  position:absolute;
  top:0;
  right:0;
  z-index:1;

  box-sizing:border-box;

  height:100%;
  width:2rem;
  padding-top:0.3rem;

  border-left:1px solid #227755;
  border-radius:0 4px 4px 0;

  background-color:#000;
  color:#fff;
  text-align:center;
}

.select .option-list{
  z-index:2;

  list-style:none;
  margin:0;
  padding:0;

  box-sizing:border-box;

  min-width:100%;

  max-height:10rem;
  overflow-y:auto;
  overflow-x:hidden;

  border:1px solid #227755;
  box-shadow:0 0 3px 1px #227755;
  background-color:#fff;
}

.select .option{
  padding:0.5rem 1rem;
}

.select .highlight{
  background-color:#227755;
  color:#fff;
}
.select{
  position:relative;
  /* 요소가 text flow의 일부가 되고 크기 조절도 가능하도록 */
  display:inline-block;
}

.select.active,
.select:focus{
  outline:none;
  box-shadow:0 0 3px 1px #227755;
}

.select .option-list{
  position:absolute;
  top:100%;
  left:0;
}

/* hidden이 되면 높이를 0으로 만들고 숨긴다 */
.select .option-list.hidden{
  max-height:0;
  visibility:hidden;
}

/* 여기부터는 장식 CSS */
.select{
  font-size:1rem;
  box-sizing:border-box;
  padding:0.5rem 1rem;

  width:10rem;

  border:1px solid #227755;
  border-radius:4px;
  box-shadow:0 0 3px 1px #227755;

  background-color:#fff;
}

.select .value{
  display:inline-block;
  width:100%;
  overflow:hidden;
  
  white-space:nowrap;
  text-overflow:ellipsis;
  vertical-align:top;
}

.select::after{
  content: "▼";
  position:absolute;
  top:0;
  right:0;
  z-index:1;

  box-sizing:border-box;

  height:100%;
  width:2rem;
  padding-top:0.3rem;

  border-left:1px solid #227755;
  border-radius:0 4px 4px 0;

  background-color:#000;
  color:#fff;
  text-align:center;
}

.select .option-list{
  z-index:2;

  list-style:none;
  margin:0;
  padding:0;

  box-sizing:border-box;

  min-width:100%;

  max-height:10rem;
  overflow-y:auto;
  overflow-x:hidden;

  border:1px solid #227755;
  box-shadow:0 0 3px 1px #227755;
  background-color:#fff;
}

.select .option{
  padding:0.5rem 1rem;
}

.select .highlight{
  background-color:#227755;
  color:#fff;
}

7.4. JS

JS는 다음과 같이 작성한다.

/* 인수로 받은 select를 hidden 상태로 바꾼다 */
function deactivateSelect(select){
  if(!select.classList.contains('active')){return;}

  const optionList = select.querySelector('.option-list');
  optionList.classList.add('hidden');
  optionList.classList.remove('active');
}

// select를 selectList중에서 활성화시킨다
function activateSelect(select, selectList){
  if(select.classList.contains('active')){return;}

  selectList.forEach(deactivateSelect);

  select.classList.add('active');
}

// select의 optionList hidden 속성을 토글한다
function toggleOptionList(select){
  const optionList = select.querySelector('.option-list');
  optionList.classList.toggle('hidden');
}

// select의 optionList에서 option을 highlight한다
// 옵션에 마우스가 올라갈 때 하이라이트를 위함
function highlightOption(select, option){
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other => {
    other.classList.remove('highlight');
  });

  option.classList.add('highlight');
}

// select의 value를 업데이트하고 aria-selected 업데이트
function updateValue(select, index){
  const value=select.querySelector('.value');
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other=>{
    other.setAttribute('aria-selected', "false");
  });

  optionList[index].setAttribute('aria-selected', "true");

  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
}

// 이벤트 리스너에 달기
window.addEventListener('load', ()=>{
  const selectList = document.querySelectorAll('.select');
  
  selectList.forEach(select => {
    const optionList = select.querySelectorAll('.option');

    select.tabIndex = 0;

    optionList.forEach((option, index) => {
      option.addEventListener('click', ()=>{
        updateValue(select, index);
      });
    });

    optionList.forEach(option => {
      option.addEventListener('mouseover', ()=>{
        highlightOption(select, option);
      })
    })

    select.addEventListener('click', (e)=>{
      toggleOptionList(select);
    });

    select.addEventListener('focus', (e)=>{
      activateSelect(select, selectList);
    });

    select.addEventListener('blur', (e)=>{
      deactivateSelect(select);
    });

    select.addEventListener('keyup', (e)=>{
      if(e.key==="Escape"){
        deactivateSelect(select);
      }
      if(e.key==="ArrowDown" && index < optionList.length - 1){
        index++;
      }
      if(e.key==="ArrowUp" && index > 0){
        index--;
      }
      updateValue(select, index);
    });

  });
})
/* 인수로 받은 select를 hidden 상태로 바꾼다 */
function deactivateSelect(select){
  if(!select.classList.contains('active')){return;}

  const optionList = select.querySelector('.option-list');
  optionList.classList.add('hidden');
  optionList.classList.remove('active');
}

// select를 selectList중에서 활성화시킨다
function activateSelect(select, selectList){
  if(select.classList.contains('active')){return;}

  selectList.forEach(deactivateSelect);

  select.classList.add('active');
}

// select의 optionList hidden 속성을 토글한다
function toggleOptionList(select){
  const optionList = select.querySelector('.option-list');
  optionList.classList.toggle('hidden');
}

// select의 optionList에서 option을 highlight한다
// 옵션에 마우스가 올라갈 때 하이라이트를 위함
function highlightOption(select, option){
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other => {
    other.classList.remove('highlight');
  });

  option.classList.add('highlight');
}

// select의 value를 업데이트하고 aria-selected 업데이트
function updateValue(select, index){
  const value=select.querySelector('.value');
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other=>{
    other.setAttribute('aria-selected', "false");
  });

  optionList[index].setAttribute('aria-selected', "true");

  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
}

// 이벤트 리스너에 달기
window.addEventListener('load', ()=>{
  const selectList = document.querySelectorAll('.select');
  
  selectList.forEach(select => {
    const optionList = select.querySelectorAll('.option');

    select.tabIndex = 0;

    optionList.forEach((option, index) => {
      option.addEventListener('click', ()=>{
        updateValue(select, index);
      });
    });

    optionList.forEach(option => {
      option.addEventListener('mouseover', ()=>{
        highlightOption(select, option);
      })
    })

    select.addEventListener('click', (e)=>{
      toggleOptionList(select);
    });

    select.addEventListener('focus', (e)=>{
      activateSelect(select, selectList);
    });

    select.addEventListener('blur', (e)=>{
      deactivateSelect(select);
    });

    select.addEventListener('keyup', (e)=>{
      if(e.key==="Escape"){
        deactivateSelect(select);
      }
      if(e.key==="ArrowDown" && index < optionList.length - 1){
        index++;
      }
      if(e.key==="ArrowUp" && index > 0){
        index--;
      }
      updateValue(select, index);
    });

  });
})
/* 인수로 받은 select를 hidden 상태로 바꾼다 */
function deactivateSelect(select){
  if(!select.classList.contains('active')){return;}

  const optionList = select.querySelector('.option-list');
  optionList.classList.add('hidden');
  optionList.classList.remove('active');
}

// select를 selectList중에서 활성화시킨다
function activateSelect(select, selectList){
  if(select.classList.contains('active')){return;}

  selectList.forEach(deactivateSelect);

  select.classList.add('active');
}

// select의 optionList hidden 속성을 토글한다
function toggleOptionList(select){
  const optionList = select.querySelector('.option-list');
  optionList.classList.toggle('hidden');
}

// select의 optionList에서 option을 highlight한다
// 옵션에 마우스가 올라갈 때 하이라이트를 위함
function highlightOption(select, option){
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other => {
    other.classList.remove('highlight');
  });

  option.classList.add('highlight');
}

// select의 value를 업데이트하고 aria-selected 업데이트
function updateValue(select, index){
  const value=select.querySelector('.value');
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other=>{
    other.setAttribute('aria-selected', "false");
  });

  optionList[index].setAttribute('aria-selected', "true");

  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
}

// 이벤트 리스너에 달기
window.addEventListener('load', ()=>{
  const selectList = document.querySelectorAll('.select');
  
  selectList.forEach(select => {
    const optionList = select.querySelectorAll('.option');

    select.tabIndex = 0;

    optionList.forEach((option, index) => {
      option.addEventListener('click', ()=>{
        updateValue(select, index);
      });
    });

    optionList.forEach(option => {
      option.addEventListener('mouseover', ()=>{
        highlightOption(select, option);
      })
    })

    select.addEventListener('click', (e)=>{
      toggleOptionList(select);
    });

    select.addEventListener('focus', (e)=>{
      activateSelect(select, selectList);
    });

    select.addEventListener('blur', (e)=>{
      deactivateSelect(select);
    });

    select.addEventListener('keyup', (e)=>{
      if(e.key==="Escape"){
        deactivateSelect(select);
      }
      if(e.key==="ArrowDown" && index < optionList.length - 1){
        index++;
      }
      if(e.key==="ArrowUp" && index > 0){
        index--;
      }
      updateValue(select, index);
    });

  });
})
/* 인수로 받은 select를 hidden 상태로 바꾼다 */
function deactivateSelect(select){
  if(!select.classList.contains('active')){return;}

  const optionList = select.querySelector('.option-list');
  optionList.classList.add('hidden');
  optionList.classList.remove('active');
}

// select를 selectList중에서 활성화시킨다
function activateSelect(select, selectList){
  if(select.classList.contains('active')){return;}

  selectList.forEach(deactivateSelect);

  select.classList.add('active');
}

// select의 optionList hidden 속성을 토글한다
function toggleOptionList(select){
  const optionList = select.querySelector('.option-list');
  optionList.classList.toggle('hidden');
}

// select의 optionList에서 option을 highlight한다
// 옵션에 마우스가 올라갈 때 하이라이트를 위함
function highlightOption(select, option){
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other => {
    other.classList.remove('highlight');
  });

  option.classList.add('highlight');
}

// select의 value를 업데이트하고 aria-selected 업데이트
function updateValue(select, index){
  const value=select.querySelector('.value');
  const optionList = select.querySelectorAll('.option');

  optionList.forEach(other=>{
    other.setAttribute('aria-selected', "false");
  });

  optionList[index].setAttribute('aria-selected', "true");

  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
}

// 이벤트 리스너에 달기
window.addEventListener('load', ()=>{
  const selectList = document.querySelectorAll('.select');
  
  selectList.forEach(select => {
    const optionList = select.querySelectorAll('.option');

    select.tabIndex = 0;

    optionList.forEach((option, index) => {
      option.addEventListener('click', ()=>{
        updateValue(select, index);
      });
    });

    optionList.forEach(option => {
      option.addEventListener('mouseover', ()=>{
        highlightOption(select, option);
      })
    })

    select.addEventListener('click', (e)=>{
      toggleOptionList(select);
    });

    select.addEventListener('focus', (e)=>{
      activateSelect(select, selectList);
    });

    select.addEventListener('blur', (e)=>{
      deactivateSelect(select);
    });

    select.addEventListener('keyup', (e)=>{
      if(e.key==="Escape"){
        deactivateSelect(select);
      }
      if(e.key==="ArrowDown" && index < optionList.length - 1){
        index++;
      }
      if(e.key==="ArrowUp" && index > 0){
        index--;
      }
      updateValue(select, index);
    });

  });
})

7.5. 작동하지 않을 때

요즘은 거의 없는 일이지만 사용자가 JS 사용 기능을 껐을 수 있다.

그리고 가장 흔한 경우는 스크립트가 어떤 네트워크 문제로 인해 로딩되지 않았거나, 스크립트에 버그가 있거나 서드파티 라이브러리 혹은 브라우저 확장과 충돌이 일어났을 수 있다.

또한 브라우저가 낡은 브라우저라서 스크립트가 사용하는 문법 중 일부를 지원하지 않아서 작동하지 않을 수도 있다.

JS가 로드되고 실행되기 전에 요소와 상호작용을 시도해서 안되는 것일 수도 있다.

이럴 때를 대비해서 그냥 <select> 태그를 fallback으로 만들어 두는 방법을 취할 수 있다. 이럴 때는 만약 커스텀 select가 로드되었다면 기본 <select>를 숨기고 커스텀 select를 표시하도록 한다.

window.addEventListener("load", () => {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});
window.addEventListener("load", () => {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});
window.addEventListener("load", () => {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});
window.addEventListener("load", () => {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});