01하단 고정 배너 슬라이드업
페이지 아래쪽에서 배너가 자기 높이만큼 떠올라 제자리에 서고, 버튼을 누르면 같은 길을 되짚어 아래로 내려갑니다.
쓰임첫 방문 쿠키 고지, 하단 고정 공지 띠
translateY 100%→0$dur-enterradius 16

<main class="stage">
<div class="cb is-demo">
<div class="cb__page" aria-hidden="true">
<div class="cb__topbar">
<span class="cb__brand"></span>
<span class="cb__nav"></span>
<span class="cb__nav"></span>
</div>
<div class="cb__lines">
<span class="cb__line cb__line--head"></span>
<span class="cb__line"></span>
<span class="cb__line"></span>
<span class="cb__line cb__line--short"></span>
</div>
</div>
<p class="cb__chip" hidden><span class="cb__chip-k">저장된 설정</span><b class="cb__chip-v"></b></p>
<section class="cb__banner" aria-label="쿠키 동의">
<span class="cb__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a48 48 0 0 0 48 48a48 48 0 0 0 48 48" opacity=".2"/><path d="M164.49 163.51a12 12 0 1 1-17 0a12 12 0 0 1 17 0m-81-8a12 12 0 1 0 17 0a12 12 0 0 0-16.98 0Zm9-39a12 12 0 1 0-17 0a12 12 0 0 0 17-.02Zm48-1a12 12 0 1 0 0 17a12 12 0 0 0 0-17M232 128A104 104 0 1 1 128 24a8 8 0 0 1 8 8a40 40 0 0 0 40 40a8 8 0 0 1 8 8a40 40 0 0 0 40 40a8 8 0 0 1 8 8m-16.31 7.39A56.13 56.13 0 0 1 168.5 87.5a56.13 56.13 0 0 1-47.89-47.19a88 88 0 1 0 95.08 95.08"/></g></svg></span>
<div class="cb__text">
<strong class="cb__title">이 사이트는 쿠키를 사용합니다</strong>
<span class="cb__desc">분석과 맞춤 표시에 씁니다</span>
</div>
<div class="cb__acts">
<button class="cb__btn" type="button" data-v="min">필수만 허용</button>
<button class="cb__btn cb__btn--ok" type="button" data-v="all">모두 허용</button>
</div>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// ── 무대: 움직이지 않는 가짜 페이지 한 장. 움직이는 건 배너뿐이다 ──
.cb {
position: relative;
box-sizing: border-box;
width: min(100%, 420px);
height: clamp(156px, 52vw, 236px);
overflow: hidden;
border: 1px solid rgba(23, 20, 26, .10);
border-radius: $r-card;
background: rgba(23, 20, 26, .04);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cb__page { position: absolute; inset: 0; }
.cb__topbar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: $stage-paper;
border-bottom: 1px solid rgba(23, 20, 26, .08);
}
.cb__brand { width: 44px; height: 8px; border-radius: $r-xs; background: rgba(23, 20, 26, .24); }
.cb__nav { width: 28px; height: 6px; border-radius: $r-xs; background: rgba(23, 20, 26, .12); }
.cb__nav:first-of-type { margin-left: auto; }
.cb__lines { display: grid; gap: $sp-2; padding: $sp-4 $sp-3; }
.cb__line {
height: 8px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .12);
}
.cb__line--head { height: 12px; width: 62%; background: rgba(23, 20, 26, .20); }
.cb__line--short { width: 48%; }
// 이전 선택 표시 칩
.cb__chip {
position: absolute;
top: $sp-8;
left: $sp-3;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .10);
color: $color;
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.2;
}
.cb__chip[hidden] { display: none; }
.cb__chip-k::after { content: ":"; }
// ── 배너: 아래에서 올라온다 ──
.cb__banner {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: $sp-2;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2 $sp-3;
padding: $sp-3;
border: 1px solid rgba(23, 20, 26, .10);
border-radius: $r-card;
background: $stage-paper;
box-shadow: 0 2px 8px rgba(23, 20, 26, .10);
transform: translateY(0);
opacity: 1;
transition: transform $dur-base $easing, opacity $dur-base $easing;
}
.cb__banner.is-out {
transform: translateY(calc(100% + #{$sp-2}));
opacity: 0;
}
.cb__ic {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: $r-pill;
background: rgba(47, 109, 246, .12);
color: $color;
}
.cb__text { flex: 1 1 128px; min-width: 0; display: grid; gap: $sp-1; }
.cb__title {
font-size: clamp(11px, 3vw, 13px);
font-weight: 700;
line-height: 1.35;
word-break: keep-all; // 한국어가 낱말 가운데서 끊기지 않게
overflow-wrap: anywhere;
}
.cb__desc {
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.35;
color: rgba($stage-ink, .72);
word-break: keep-all;
overflow-wrap: anywhere;
}
.cb__acts { flex: 0 0 auto; display: flex; gap: $sp-2; }
.cb__btn {
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-control;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: clamp(10px, 2.8vw, 12px);
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
padding: $sp-2 $sp-3;
cursor: pointer;
transition: background $dur-quick $ease-out, transform $dur-quick $ease-out;
}
.cb__btn--ok {
border-color: transparent;
background: $subject-blue-deep;
color: $subject-cream;
}
.cb__btn:hover, .cb__btn:focus-visible { background: rgba(23, 20, 26, .06); }
.cb__btn--ok:hover, .cb__btn--ok:focus-visible { background: rgba(47, 109, 246, .84); }
.cb__btn:active { transform: translateY(1px); }
// ── 자동 루프: 올라왔다 머물다 다시 내려간다. 0% 와 100% 가 같아 텔레포트가 없다 ──
.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; }
}
@media (max-width: 340px) {
.cb__lines { padding: $sp-3 $sp-3; gap: $sp-1; }
.cb__banner { gap: $sp-2; padding: $sp-2; }
.cb__ic { width: 24px; height: 24px; }
.cb__acts { width: 100%; }
.cb__btn { flex: 1 1 auto; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__banner { animation: none !important; transform: translateY(0); opacity: 1; }
.cb__banner, .cb__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
position: relative;
box-sizing: border-box;
width: min(100%, 420px);
height: clamp(156px, 52vw, 236px);
overflow: hidden;
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cb__page {
position: absolute;
inset: 0;
}
.cb__topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #ffffff;
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.cb__brand {
width: 44px;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.24);
}
.cb__nav {
width: 28px;
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.cb__nav:first-of-type {
margin-left: auto;
}
.cb__lines {
display: grid;
gap: 8px;
padding: 16px 12px;
}
.cb__line {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.cb__line--head {
height: 12px;
width: 62%;
background: rgba(23, 20, 26, 0.2);
}
.cb__line--short {
width: 48%;
}
.cb__chip {
position: absolute;
top: 32px;
left: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.1);
color: #2f6df6;
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.2;
}
.cb__chip[hidden] {
display: none;
}
.cb__chip-k::after {
content: ":";
}
.cb__banner {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 20, 26, 0.1);
transform: translateY(0);
opacity: 1;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cb__banner.is-out {
transform: translateY(calc(100% + 8px));
opacity: 0;
}
.cb__ic {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.cb__text {
flex: 1 1 128px;
min-width: 0;
display: grid;
gap: 4px;
}
.cb__title {
font-size: clamp(11px, 3vw, 13px);
font-weight: 700;
line-height: 1.35;
word-break: keep-all;
overflow-wrap: anywhere;
}
.cb__desc {
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.35;
color: rgba(23, 20, 26, 0.72);
word-break: keep-all;
overflow-wrap: anywhere;
}
.cb__acts {
flex: 0 0 auto;
display: flex;
gap: 8px;
}
.cb__btn {
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: clamp(10px, 2.8vw, 12px);
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
padding: 8px 12px;
cursor: pointer;
transition: background 220ms ease-out, transform 220ms ease-out;
}
.cb__btn--ok {
border-color: transparent;
background: #0a4fe8;
color: #fff7e6;
}
.cb__btn:hover, .cb__btn:focus-visible {
background: rgba(23, 20, 26, 0.06);
}
.cb__btn--ok:hover, .cb__btn--ok:focus-visible {
background: rgba(47, 109, 246, 0.84);
}
.cb__btn:active {
transform: translateY(1px);
}
.cb.is-demo .cb__banner {
animation: cb-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cb-slide {
0% {
transform: translateY(calc(100% + 8px));
opacity: 0;
}
22%, 72% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(calc(100% + 8px));
opacity: 0;
}
}
@media (max-width: 340px) {
.cb__lines {
padding: 12px 12px;
gap: 4px;
}
.cb__banner {
gap: 8px;
padding: 8px;
}
.cb__ic {
width: 24px;
height: 24px;
}
.cb__acts {
width: 100%;
}
.cb__btn {
flex: 1 1 auto;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__banner {
animation: none !important;
transform: translateY(0);
opacity: 1;
}
.cb__banner, .cb__btn {
transition: none;
}
}// 실제 동작: 버튼을 누르면 배너가 아래로 빠지고 고른 값이 localStorage 에 남는다.
// 칩 글자는 누른 버튼의 라벨을 그대로 복사한다 — 스크립트 안에 문구를 두 번 적지 않는다.
var cb = document.querySelector('.cb');
var chip = cb.querySelector('.cb__chip');
var chipV = cb.querySelector('.cb__chip-v');
var banner = cb.querySelector('.cb__banner');
var KEY = 'gd-consent-01';
function load() {
try { return localStorage.getItem(KEY); } catch (e) { return null; }
}
function save(v) {
try { localStorage.setItem(KEY, v); } catch (e) {}
}
function mark(v) {
var b = cb.querySelector('.cb__btn[data-v="' + v + '"]');
if (!b) return;
chipV.textContent = b.textContent;
chip.hidden = false;
}
function pick(e) {
var v = e.currentTarget.getAttribute('data-v');
save(v);
cb.classList.remove('is-demo');
banner.classList.add('is-out');
mark(v);
}
var btns = cb.querySelectorAll('.cb__btn');
for (var i = 0; i < btns.length; i++) btns[i].addEventListener('click', pick);
// 저장값이 있어도 배너는 다시 띄운다(루프가 필요하다) — 대신 이전 선택을 칩으로 보여준다
var saved = load();
if (saved) mark(saved);import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomBarSlideUp.module.scss";
export type BottomBarSlideUpProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const KEY = "gd-consent-01";
const LABEL: { [k: string]: string } = {
min: "Necessary only",
all: "Accept all",
};
function load(): string {
try {
return localStorage.getItem(KEY) || "";
} catch (e) {
return "";
}
}
export default function BottomBarSlideUp({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BottomBarSlideUpProps) {
const [saved, setSaved] = useState<string>("");
const [out, setOut] = useState<boolean>(false);
const [live, setLive] = useState<boolean>(true);
// 저장값이 있어도 배너는 다시 띄운다(루프가 필요하다) — 이전 선택만 칩으로 보여준다
useEffect(() => {
setSaved(load());
}, []);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
function pick(v: string): void {
try {
localStorage.setItem(KEY, v);
} catch (e) {
/* 샌드박스에서 던진다 */
}
setSaved(v);
setLive(false);
setOut(true);
}
return (
<div className={`${styles.cb} ${live ? styles.isDemo : ""}`} style={vars}>
<div className={styles.page} aria-hidden="true">
<div className={styles.topbar}>
<span className={styles.brand} />
<span className={styles.nav} />
<span className={styles.nav} />
</div>
<div className={styles.lines}>
<span className={`${styles.line} ${styles.lineHead}`} />
<span className={styles.line} />
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
</div>
</div>
{saved ? (
<p className={styles.chip}>
<span className={styles.chipK}>Saved</span>
<b className={styles.chipV}>{LABEL[saved]}</b>
</p>
) : null}
<section
className={`${styles.banner} ${out ? styles.isOut : ""}`}
aria-label="Cookie consent"
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path
d="M224 128a96 96 0 1 1-96-96a48 48 0 0 0 48 48a48 48 0 0 0 48 48"
opacity=".2"
/>
<path d="M164.49 163.51a12 12 0 1 1-17 0a12 12 0 0 1 17 0m-81-8a12 12 0 1 0 17 0a12 12 0 0 0-16.98 0Zm9-39a12 12 0 1 0-17 0a12 12 0 0 0 17-.02Zm48-1a12 12 0 1 0 0 17a12 12 0 0 0 0-17M232 128A104 104 0 1 1 128 24a8 8 0 0 1 8 8a40 40 0 0 0 40 40a8 8 0 0 1 8 8a40 40 0 0 0 40 40a8 8 0 0 1 8 8m-16.31 7.39A56.13 56.13 0 0 1 168.5 87.5a56.13 56.13 0 0 1-47.89-47.19a88 88 0 1 0 95.08 95.08" />
</g>
</svg>
</span>
<div className={styles.text}>
<strong className={styles.title}>This site uses cookies</strong>
<span className={styles.desc}>For analytics and personalised content</span>
</div>
<div className={styles.acts}>
<button className={styles.btn} type="button" onClick={() => pick("min")}>
{LABEL.min}
</button>
<button
className={`${styles.btn} ${styles.btnOk}`}
type="button"
onClick={() => pick("all")}
>
{LABEL.all}
</button>
</div>
</section>
</div>
);
}01 하단 고정 배너 슬라이드업 — 측정 누적면적 10.36% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 쿠키 동의 배너 UI 9종 — 그대로 붙이는 동의 관리 코드
이 데모가 속한 글
쿠키 동의 배너 UI 9종 — 그대로 붙이는 동의 관리 코드쿠키 동의 배너는 사이트가 쿠키를 쓴다는 사실을 알리고 방문자의 선택을 받아 두는 UI입니다. 여기 아홉 개는 배너·패널·모달·토스트를 모두 실제로 눌러 볼 수 있는 상태로 만든 것입니다. 그중 하나는 끝까지 읽기 이 글의 데모 전부 보기


