GODRICH

쿠키 동의 배너 UI 9종 — 그대로 붙이는 동의 관리 코드

쿠키 동의 배너는 사이트가 쿠키를 쓴다는 사실을 알리고 방문자의 선택을 받아 두는 UI입니다. 여기 아홉 개는 배너·패널·모달·토스트를 모두 실제로 눌러 볼 수 있는 상태로 만든 것입니다. 그중 하나는 끝까지 읽기 전에는 버튼이 눌리지 않습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 방문자가 동의를 만나는 순서입니다. 화면 아래에서 처음 올라오는 띠로 시작해, 항목을 따로 고르는 패널과 한 번 닫은 뒤 다시 여는 방법을 거쳐, 자리와 크기를 줄인 모서리 카드와 한 줄 띠로 이어집니다. 그다음이 허용과 거부를 나란히 세우는 방식, 동의한 순간의 작은 모션, 끝까지 읽어야 열리는 게이트이며, 마지막은 저장됐다는 확인입니다. 아홉 개 모두 방문자의 선택을 localStorage 에 저장하므로 브라우저를 껐다 켜도 값이 그대로 남습니다.

번호 어디에 뜨나 움직이는 속성
01 · 05 화면 맨 아래 가로 띠 translateY
02 카드 안 세 줄 grid-template-rows · translateX
03 무대 가운데 + 왼쪽 아래 버튼 scale · opacity
04 오른쪽 아래 모서리 translateY · rotate · scale
06 카드 안 버튼 두 개 opacity · scale
07 가운데 아이콘 + 부스러기 셋 translate · scale
08 카드 위 진행 막대 scaleX
09 오른쪽 위 토스트 translateX · scaleX

01하단 고정 배너 슬라이드업

배너가 자기 높이에 $sp-2 를 더한 만큼 아래에 숨어 있다가 제자리로 올라오고, 버튼을 누르면 같은 값만큼 다시 내려갑니다. 어느 버튼을 눌렀는지는 목업 위 작은 칩에 「저장된 설정」으로 남습니다.

translateY 100%→0$dur-enterradius 16
.cb.is-demo .cb__banner { animation: cb-slide $dur-loop $easing infinite; }

@keyframes cb-slide {
  0%        { transform: translateY(calc(100% + #{$sp-2})); opacity: 0; }
  22%, 72%  { transform: translateY(0); opacity: 1; }
  100%      { transform: translateY(calc(100% + #{$sp-2})); opacity: 0; }
}

02카테고리별 토글 상세 패널

필수·통계·마케팅 세 줄이 각각 스위치를 달고 있고, 제목을 누르면 설명 칸이 0fr 에서 1fr 로 펼쳐집니다. 높이를 auto 로 두면 전환이 걸리지 않으므로 격자 행 크기를 대신 바꿉니다.

grid 0fr→1fr노브 translateXradius 16
.ct__panel {
  grid-column: 1 / -1;
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.ct__inner { overflow: hidden; }
.ct__row.is-open .ct__panel { grid-template-rows: 1fr; }

.ct__knob { transform: translateX(0); transition: transform $duration $easing; }
.ct__sw.is-on .ct__knob { transform: translateX(16px); }

03동의 관리 모달 재열기

왼쪽 아래 동그란 버튼이 한 번 부풀었다 돌아오고, 누르면 백드롭이 깔리면서 모달이 .92 에서 1 로 커집니다. 열 때는 모달 안 첫 버튼으로, 닫을 때는 다시 여는 버튼으로 포커스가 돌아옵니다.

scale .92→1백드롭 opacityradius 16
.mo__modal {
  width: min(100%, 264px);
  opacity: 0;
  transform: scale(.92);
  pointer-events: none;
  transition: transform $duration $easing, opacity $duration $ease-out;
}
.mo.is-open .mo__modal { opacity: 1; transform: scale(1); pointer-events: auto; }

.mo__backdrop { background: rgba(23, 20, 26, .44); opacity: 0; pointer-events: none; }
.mo.is-open .mo__backdrop { opacity: 1; pointer-events: auto; }

04첫 방문 모서리 카드

카드가 4도 기운 채 들어와 각도를 펴고, 목적을 적은 세 줄이 각각 0.10초·0.16초·0.22초씩 늦게 따라 올라옵니다. 같은 키프레임에 지연만 다르게 주면 줄마다 애니메이션을 새로 쓸 필요가 없습니다.

rotate 4deg→00.1s 시차radius 16
@keyframes fv-card {
  0%, 100%  { opacity: 0; transform: translateY(16px) rotate(-4deg) scale(.96); }
  18%, 78%  { opacity: 1; transform: translateY(0) rotate(0deg) scale(1); }
}

.fv__card.is-demo { animation: fv-card $duration $easing infinite; }
.fv__card.is-demo .fv__row { animation: fv-row $duration $easing infinite; }
.fv__card.is-demo .fv__row--a { animation-delay: .10s; }
.fv__card.is-demo .fv__row--b { animation-delay: .16s; }
.fv__card.is-demo .fv__row--c { animation-delay: .22s; }

05아이콘 한 줄 미니 배너

높이 44px 짜리 띠가 본문을 거의 가리지 않고 올라오고, 띠 안에서는 쿠키 아이콘만 좌우로 기웁니다. 기울기가 -6deg → 6deg → -6deg 로 돌아오므로 한 바퀴가 끝나도 되감기가 보이지 않습니다.

높이 44px 고정rotate ±6degradius 12
@keyframes mb-up {
  0%, 100%  { transform: translateY(100%); }
  20%, 82%  { transform: translateY(0); }
}
@keyframes mb-tilt {
  0%   { transform: rotate(-6deg); }
  50%  { transform: rotate(6deg); }
  100% { transform: rotate(-6deg); }
}

.mb__bar.is-demo { animation: mb-up $duration $easing infinite; }
.mb__bar.is-demo .mb__cookie { animation: mb-tilt $duration $ease-out infinite; }

06거부도 한 번에 — 동등 강조 버튼

두 버튼이 같은 2px 테두리와 같은 글자 굵기로 서고 허용 쪽만 배경이 채워집니다. 초점 링은 버튼마다 ::after 로 하나씩 두고 투명도와 크기로 번갈아 보여 주므로, 두 버튼의 폭이 달라져도 링이 엉뚱한 자리에 걸리지 않습니다.

outline 2pxscale 0.97 누름radius 12
.ep__pair { display: grid; grid-template-columns: 1fr 1fr; gap: $sp-2; }
.ep__btn {
  border: 2px solid $color;
  background: $stage-paper;
  color: $color;
  font-weight: 700;
}
.ep__btn--accept { background: $color; color: $stage-paper; }
.ep__btn::after {
  content: "";
  position: absolute;
  inset: -6px;
  border: 3px solid $color;
  border-radius: $r-control;
  opacity: 0;
}

07부서지는 쿠키 아이콘

쿠키가 눌렸다 부풀어 오르는 사이 부스러기 셋이 각자 다른 각도로 나갔다 제자리로 돌아옵니다. 자동 루프와 버튼으로 켜는 한 번짜리가 같은 키프레임을 쓰고, 반복 횟수만 뒤에서 덮어씁니다.

scale .88→1.08부스러기 3개 왕복radius 50%
@keyframes ck-crumb-a {
  0%   { transform: translate(0, 0) scale(.4);        opacity: 0; }
  16%  { transform: translate(-12px, -10px) scale(1); opacity: 1; }
  46%  { transform: translate(-34px, -26px) scale(1); opacity: 1; }
  78%  { transform: translate(-10px, -8px) scale(.7); opacity: .5; }
  100% { transform: translate(0, 0) scale(.4);        opacity: 0; }
}

.ck.is-demo .ck__crumb--a,
.ck.is-burst .ck__crumb--a { animation: ck-crumb-a $duration $easing infinite; }
.ck.is-burst .ck__icon,
.ck.is-burst .ck__crumb { animation-iteration-count: 1; }

08스크롤 연속 동의 게이트

약관 상자를 끝까지 내려야 동의 버튼의 disabled 가 풀리고, 진행 막대는 실제 scrollTop 비율을 --p 로 받아 scaleX 로 찹니다. 자동 루프는 스크롤 위치를 건드리지 않고 안쪽 줄만 위로 흘리며, 막대와 버튼은 같은 박자로 따라갑니다.

scaleX 0→1트랙만 translateYradius 12
sgBox.addEventListener('scroll', function () {
  sg.classList.remove('is-demo');
  var max = sgBox.scrollHeight - sgBox.clientHeight;
  var p = max > 0 ? sgBox.scrollTop / max : 1;
  sg.style.setProperty('--p', String(p < 1 ? p : 1));
  if (sgBox.scrollTop + sgBox.clientHeight >= sgBox.scrollHeight - 4) { sgReady(); }
});

09설정 저장 토스트

토스트가 오른쪽에서 들어와, 타이머 선이 오른쪽 끝으로 줄어드는 동안 머물다가 같은 방향으로 나갑니다. 저장 횟수와 마지막 시각이 둘째 줄에 붙으므로 연타해도 값이 바뀌는 것이 보입니다.

translateX 120%→0scaleX 1→0 타이머radius 12
@keyframes ts-slide {
  0%   { transform: translateX(120%); }
  14%  { transform: translateX(0); }
  86%  { transform: translateX(0); }
  100% { transform: translateX(120%); }
}
@keyframes ts-timer {
  0%   { transform: scaleX(1); }
  14%  { transform: scaleX(1); }
  86%  { transform: scaleX(0); }
  99%  { transform: scaleX(0); }
  100% { transform: scaleX(1); }
}

어디서 깨지는가 — 함정

가장 오래 붙들린 것은 08 번이었습니다. 480×300 에서는 멀쩡한데 320×200 렌더에서만 문서 높이가 208px 로 나와 8px 이 삐져나왔고, 원인은 좁은 화면 보정 블록에서 약관 상자를 88px 로만 줄여 둔 것이었습니다. 상자를 76px 로 낮추고 카드 안쪽 여백을 한 눈금 줄이자 같은 측정이 넘침 없음으로 바뀌었습니다. 배너류는 세로로 쌓이는 조각이 많아 좁은 화면에서 몇 px 씩 넘치기 쉬우니, 눈대중하지 말고 좁은 뷰포트에서 실제 문서 높이를 찍어 보는 편이 빠릅니다. 두 번째는 09 번 타이머 선입니다. scaleX(1) 에서 scaleX(0) 으로 줄어든 선을 그대로 두면 다음 바퀴 시작에서 다시 꽉 찬 선으로 튀어 보이기 때문에, 토스트가 이미 화면 밖에 있는 99% 지점에서 몰래 되감아 0% 와 100% 를 같게 만들었습니다. 세 번째는 코드가 아니라 글자였습니다. 아홉 개가 하나의 데모 사전을 같이 쓰는데 01 번의 상태 칩과 04 번의 버튼이 둘 다 「설정」이라는 같은 한국어를 쓰는 바람에, 한쪽은 저장된 값을, 다른 한쪽은 상세 화면으로 가는 길을 뜻하는데도 영어 한 줄로는 둘 다 담기지 않았습니다. 01 번 라벨을 「저장된 설정」으로 바꿔 키를 갈라놓고서야 세 언어가 어긋나지 않았습니다. 이 세 가지를 다 반영한 아홉 벌이 zip 비밀번호 r26nqrbj 로 열리고, 리액트 판은 같은 마크업을 CSS 모듈 클래스명으로 옮긴 것입니다. 마지막으로 03 번은 백드롭이 화면 전체를 덮는 구조라 렌더 측정에서 변한 픽셀이 67.8% 로 나왔는데, 이는 버그가 아니라 모달이 원래 그런 UI 라는 뜻입니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce 에서 반복 애니메이션과 전환을 끄고 클릭 동작은 그대로 둡니다. 01·04·05 는 배너와 카드가 이미 올라와 있는 상태로, 03 은 모달이 열려 있는 상태로, 08 은 진행 막대가 다 찬 상태로 멈춥니다. 06 은 왼쪽 거부 버튼에 초점 링이 선 채로 서고, 07 은 부스러기가 흩어진 자리에 남고, 09 는 토스트가 들어와 있는 자리에 섭니다. 02 는 남길 결과가 따로 없어 접힌 기본 모습으로 돌아가고, 대신 스위치와 펼침은 사람이 누를 때만 바뀝니다. 조작 요소는 button 과 체크박스뿐이라 모두 탭으로 닿고, 스위치는 role="switch"aria-checked, 펼침은 aria-expanded, 03 번 모달은 role="dialog"aria-modal 을 씁니다. 09 번 토스트에는 role="status"aria-live="polite" 가 있어 화면을 보지 않아도 저장됐다는 안내가 읽힙니다. 자세한 조건은 MDN prefers-reduced-motion 문서에 정리돼 있습니다.

FAQ

이 코드를 그대로 붙이면 GDPR 을 지키는 건가요?

아닙니다. 여기 있는 것은 화면과 조작만 만든 UI 예제이고, 법이 요구하는 것은 동의를 받기 전까지 통계·광고 스크립트를 아예 실행하지 않는 것과 동의 기록을 남기는 것입니다. 이 아홉 개는 선택을 localStorage 에 넣을 뿐이므로, 실제 서비스에서는 그 값을 서버나 쿠키로 옮기고 스크립트 로딩을 그 값에 묶어야 합니다.

배너가 뜰 때 본문이 아래로 밀리는데 왜 그런가요?

배너를 문서 흐름 안에 두고 높이를 키우는 방식으로 만들었기 때문입니다. 본문 위에 떠오르는 01·03·04·05·09 는 배너를 position: absolute 로 띄우고 transform 으로만 움직여서, 배너가 나타나도 본문 글자의 위치 계산이 다시 일어나지 않습니다. 자세한 것은 MDN translateY 문서에 있습니다.

거부 버튼을 작게 만들면 동의율이 오르지 않나요?

당장은 오르지만 감독기관이 보는 기준에서는 바로 그 점이 문제입니다. 06 번이 두 버튼을 같은 크기·같은 굵기로 세운 이유이고, 거부를 눌러도 사이트를 쓰는 데 지장이 없다는 문장을 버튼 위에 두는 것까지가 한 벌입니다.

같은 화면 조각을 더 찾는다면 모달 애니메이션 9종에서 열고 닫는 방식을, 토스트 알림 9종에서 사라지는 알림의 다른 형태를, 토글 스위치 9종에서 스위치만 따로 가져다 붙일 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.