고급스러운 유리 질감 표현하기

Uiverse의 Generate 버튼을 하나씩 분해하며, 가상 요소와 box-shadow, animation-delay, position이 실제 UI 안에서 어떤 역할을 하는지 정리한 글

#CSS

https://uiverse.io/dexter-st/young-newt-20

핵심

이 컴포넌트는 3장의 레이어를 겹쳐서 만들었다.

레이어정체역할
::before버튼보다 4px 큰 유령 레이어 (뒤)바깥 테두리 + 아래에서 올라오는 빛
.btn진짜 버튼유리 질감 본체
::after버튼과 같은 크기 (위)파란 네온 오버레이

레이어 구조 특징

  1. .btn에는 position 속성이 없다.

  2. 그래서 ::beforeposition: absolute.btn-wrapper를 기준으로 잡힌다.

    <div class="btn-wrapper">
      <button class="btn">
      
      ...
    .btn::before { ... }
    .btn::after { ... }
  3. wrapper가 inline-block이라 버튼 크기에 딱 맞고, 결과적으로 width: calc(100% + 8px)가 정확히 "버튼보다 4px씩 큰 액자"가 된다.

  4. wrapper가 없으면 이 구조가 통째로 무너지게 된다.

왜 가상요소인가

  1. 장식은 DOM에 존재할 필요가 없기 때문이다.

  2. 가상요소 없이도 만들 수 있다.

    <button class="btn">
      <div class="bezel"></div>    <!-- 바깥 테두리 -->
      <div class="glow"></div>     <!-- 파란 오버레이 -->
      <svg>...</svg>
      <div class="txt-wrapper">...</div>
    </button>
  3. 하지만 버튼 안에 튼 안에 아무 의미 없는 div 두 개가 들어간다.

    • 문서의 원래 의미를 파악하기 힘들어진다.
    • 불필요한 div가 접근성을 해치지는 않지만 시각적 꾸밈은 CSS에게 역할을 맡기는게 합리적인 패턴이다.
  4. 가상요소로 구현한 기능을 시각적 차이를 보면 다음과 같다.

유리 질감 특징

inset 0px 1px 1px  rgba(255,255,255,0.2)
inset 0px 2px 2px  rgba(255,255,255,0.15)
inset 0px 4px 4px  rgba(255,255,255,0.1)
...
  1. 1 → 2 → 4 → 8 → 16px으로 두 배씩 키우면서 투명도는 낮춘다.

    :scope {
       background: #0a0a0a;
     }
     .btn {
       padding: 18px 44px;
       border: 1px solid rgba(255, 255, 255, 0.15);
       border-radius: 24px;
       background: #101010;
       color: #ffffff;
       font-size: 18px;
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.7);
     }
    .btn {
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.7),
         inset 0px 2px 2px rgba(255, 255, 255, 0.5);
     }
    .btn {
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.7),
         inset 0px 2px 2px rgba(255, 255, 255, 0.5),
         inset 0px 4px 4px rgba(255, 255, 255, 0.35);
     }
    .btn {
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.7),
         inset 0px 2px 2px rgba(255, 255, 255, 0.5),
         inset 0px 4px 4px rgba(255, 255, 255, 0.35),
         inset 0px 8px 8px rgba(255, 255, 255, 0.25);
     }
    .btn {
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.7),
         inset 0px 2px 2px rgba(255, 255, 255, 0.5),
         inset 0px 4px 4px rgba(255, 255, 255, 0.35),
         inset 0px 8px 8px rgba(255, 255, 255, 0.25),
         inset 0px 16px 16px rgba(255, 255, 255, 0.2);
     }
  2. 이는 실제 빛의 감쇠 곡선을 흉내 내기 위한 기법이다.

  3. 그림자 하나로 0 16px 16px만 쓰면 밋밋한 뿌연 덩어리가 되는데, 여러 겹을 쌓으면 가장자리는 선명하고 안쪽은 부드럽게 퍼지는 진짜 유리 광택이 나온다.

    한 겹
    다섯 겹
    :scope {
       background: #0a0a0a;
     }
     .col {
       display: grid;
       gap: 10px;
       justify-items: center;
     }
     .cap {
       color: #8a8a8a;
       font-size: 13px;
     }
     .btn {
       padding: 16px 36px;
       border: 1px solid rgba(255, 255, 255, 0.12);
       border-radius: 24px;
       background: #141414;
       color: #ffffff;
       font-size: 16px;
     }
    .one {
       box-shadow: inset 0px 16px 16px rgba(255, 255, 255, 0.45);
     }
    .many {
       box-shadow:
         inset 0px 1px 1px rgba(255, 255, 255, 0.45),
         inset 0px 2px 2px rgba(255, 255, 255, 0.35),
         inset 0px 4px 4px rgba(255, 255, 255, 0.25),
         inset 0px 8px 8px rgba(255, 255, 255, 0.15),
         inset 0px 16px 16px rgba(255, 255, 255, 0.1);
     }
  4. 그리고 바깥 그림자는 y값이 전부 음수이다.

  5. 보통 그림자는 아래로 지는데, 이건 위쪽으로 지게 만들었다.

  6. 광원이 아래에 있다는 뜻이고, 그래서 "아래에서 빛을 받는 유리판" 느낌이 난다.

    y 양수 · 아래로 진다
    y 음수 · 위로 진다
    :scope {
       background: #0a0a0a;
       padding: 64px;
     }
     .col {
       display: grid;
       gap: 14px;
       justify-items: center;
     }
     .cap {
       color: #8a8a8a;
       font-size: 13px;
     }
     .btn {
       padding: 16px 36px;
       border: 1px solid rgba(255, 255, 255, 0.12);
       border-radius: 24px;
       background: #141414;
       color: #ffffff;
       font-size: 16px;
     }
    .down {
       box-shadow: 0px 18px 24px rgba(125, 211, 252, 0.55);
     }
    .up {
       box-shadow: 0px -18px 24px rgba(125, 211, 252, 0.55);
     }

글자가 흘러가는 원리

  1. 버튼에는 Generate 글자가 순서대로 빛나는 효과가 있다.

  2. 이는 Generate를 8개의 <span>으로 쪼갠 뒤에 각 글자에 스타일을 넣어 구현했다. 가장 신박하다고 느낀 부분이다.

    <!-- 글자 하나하나를 span으로 분리해야 개별 delay를 줄 수 있음 -->
    <div class="txt">
      <span class="btn-letter">G</span>
      <span class="btn-letter">e</span>
      <span class="btn-letter">n</span>
      <span class="btn-letter">e</span>
      <span class="btn-letter">r</span>
      <span class="btn-letter">a</span>
      <span class="btn-letter">t</span>
      <span class="btn-letter">e</span>
    </div>
    ...
    .txt {
      /* span을 줄바꿈해서 쓰면 태그 사이 공백이 실제 띄어쓰기로 렌더링된다.
         그 공백을 상쇄해서 글자가 붙어 보이게 하는 트릭 */
      word-spacing: -1em;
    }
    
    .btn-letter {
      display: inline-block;   /* inline이면 transform/scale이 안 먹으므로 필수 */
      color: #fff5;            /* 평소 상태: 알파 5/15 정도의 흐린 흰색 = "꺼진 글자" */
    
      /* 2초 주기로 무한 반복. 아래 nth-child delay와 합쳐져 물결처럼 흐름 */
      animation: letter-anim 2s ease-in-out infinite;
    
      transition: color 0.4s, text-shadow 0.4s;
    }
    
    /* 0% / 100%는 생략 → 자동으로 원래 상태(#fff5, 그림자 없음)에서 시작·복귀.
       중간 지점에서만 밝아지므로 "켜졌다 꺼지는" 왕복 운동이 됨 */
    @keyframes letter-anim {
      50% {
        color: #fff;                  /* 완전한 흰색으로 점등 */
        text-shadow: 0 0 3px #fff8;   /* 은은한 발광(glow) */
      }
    }
    
    /* 핵심: 글자마다 0.08초씩 시작을 늦춤.
       전체 지연(8글자 × 0.08 = 0.64s)이 애니메이션 주기(2s)보다 짧아야
       끊기지 않고 왼쪽→오른쪽으로 흐르는 것처럼 보임 */
    .btn-letter:nth-child(1) { animation-delay: 0s; }
    .btn-letter:nth-child(2) { animation-delay: 0.08s; }
    .btn-letter:nth-child(3) { animation-delay: 0.16s; }
    .btn-letter:nth-child(4) { animation-delay: 0.24s; }
    .btn-letter:nth-child(5) { animation-delay: 0.32s; }
    .btn-letter:nth-child(6) { animation-delay: 0.40s; }
    .btn-letter:nth-child(7) { animation-delay: 0.48s; }
    .btn-letter:nth-child(8) { animation-delay: 0.56s; }
  3. 애니메이션 자체는 모든 글자가 동일하지만, 시작 시각을 다르게 하여 바뀌는 듯한 시각 효과를 주었다.

    G   0s
    e   0.08s
    n   0.16s
    e   0.24s  ...
    /* Animation delays for .btn-letter elements */
    .btn-letter:nth-child(1),
    .btn:focus .btn-letter:nth-child(1) {
      animation-delay: 0s;
    }
    .btn-letter:nth-child(2),
    .btn:focus .btn-letter:nth-child(2) {
      animation-delay: 0.08s;
    }
    .btn-letter:nth-child(3),
    .btn:focus .btn-letter:nth-child(3) {
      animation-delay: 0.16s;
    }
    
    ...
  4. 0.08초씩 밀린 같은 애니메이션이 나란히 놓이면, 밝아지는 지점이 왼쪽에서 오른쪽으로 이동하는 것처럼 보인다.

  5. CSS만으로 텍스트 일부에 시차를 줄 방법이 없어서 span으로 쪼갠 것이다.

기타 CSS 문법 공부해보기

1. position: absolute 에 대하여

  1. position: absolute를 쓴 요소는 반드시 기준점이 될 조상을 하나 찾아야 한다.

  2. 규칙은 딱 하나다. 부모부터 위로 올라가면서 positionstatic이 아닌 첫 번째 조상을 기준으로 삼는다. 끝까지 없으면 페이지 전체가 기준.

  3. 참고로 position: static은 모든 요소의 기본값이다. 즉 아무것도 안 쓴 요소는 넘어간다.

  4. 이 버튼의 구조는 다음과 같다.

    .btn-wrapper  →  position: relative ✅ 기준
      .btn        →  (없음) = static     ❌ 기준 아님
        ::before  →  position: absolute  "기준 어디?"
  5. ::before 부모 .btnstatic이기 때문에 넘어가고, .btn-wrapper 를 기준으로 삼는다.

  6. 왜 이렇게 구현했을까? .btnposition: relative를 추가하면 2px이 어긋나게 된다.

  7. % 크기를 계산할 때 기준이 되는 영역이 다르기 때문이다.

    기준width: 100%가 가리키는 것
    .btn-wrapper (현재)버튼의 테두리 바깥까지
    .btn (relative 추가 시)버튼의 테두리 안쪽까지
  8. .btn에는 border: 1px이 있어서 좌우 합쳐 2px 차이가 난다.