02Category toggle panel
Necessary, analytics, and marketing each carry their own switch, and pressing a row grows its description from 0fr to 1fr while the caret turns 180 degrees.
Best forSettings screens that let each consent category be chosen separately, and terms-detail accordions
grid 0fr→1fr노브 translateXradius 16

<main class="stage">
<div class="ct is-demo">
<h2 class="ct__head">Cookie settings</h2>
<div class="ct__row" data-k="need">
<span class="ct__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 56v56c0 96-88 120-88 120s-88-24-88-120V56a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-21.81C61.82 179.51 48 149.3 48 112V56h160ZM82.34 141.66a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0Z"/></g></svg></span>
<button class="ct__name" type="button" aria-expanded="false" aria-controls="ct-p1">
<span class="ct__label">Necessary</span>
<span class="ct__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<button class="ct__sw ct__sw--fixed is-on" type="button" role="switch" aria-checked="true" aria-disabled="true" aria-label="Necessary cookies"><span class="ct__knob"></span></button>
<div class="ct__panel" id="ct-p1"><div class="ct__inner"><p class="ct__desc">Required for the site to load at all.</p></div></div>
</div>
<div class="ct__row ct__row--stats" data-k="stats">
<span class="ct__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg></span>
<button class="ct__name" type="button" aria-expanded="false" aria-controls="ct-p2">
<span class="ct__label">Analytics</span>
<span class="ct__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<button class="ct__sw is-on" type="button" role="switch" aria-checked="true" aria-label="Analytics cookies"><span class="ct__knob"></span></button>
<div class="ct__panel" id="ct-p2"><div class="ct__inner"><p class="ct__desc">Counts which posts get read, and how far.</p></div></div>
</div>
<div class="ct__row ct__row--ads" data-k="ads">
<span class="ct__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M240 120a40 40 0 0 1-40 40h-40V80h40a40 40 0 0 1 40 40" opacity=".2"/><path d="M248 120a48.05 48.05 0 0 0-48-48h-39.8c-2.91-.17-53.62-3.74-101.91-44.24A16 16 0 0 0 32 40v160a16 16 0 0 0 26.29 12.25c37.77-31.68 77-40.76 93.71-43.3v31.72a16 16 0 0 0 7.12 13.33l11 7.33A16 16 0 0 0 194.5 212l11.77-44.36A48.07 48.07 0 0 0 248 120M48 199.93V40c42.81 35.91 86.63 45 104 47.24v65.48c-17.35 2.28-61.16 11.35-104 47.21m131 8v.11l-11-7.33V168h21.6ZM200 152h-32V88h32a32 32 0 1 1 0 64"/></g></svg></span>
<button class="ct__name" type="button" aria-expanded="false" aria-controls="ct-p3">
<span class="ct__label">Marketing</span>
<span class="ct__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<button class="ct__sw" type="button" role="switch" aria-checked="false" aria-label="Marketing cookies"><span class="ct__knob"></span></button>
<div class="ct__panel" id="ct-p3"><div class="ct__inner"><p class="ct__desc">Shows ads picked from your interests.</p></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ct {
box-sizing: border-box;
width: min(100%, 400px);
display: grid;
gap: $sp-2;
padding: $sp-4;
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);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ct__head {
margin: 0;
font-size: clamp(13px, 3.4vw, 15px);
font-weight: 700;
line-height: 1.2;
}
// 320px 에서도 아이콘·글자·스위치가 겹치지 않게 세 칸으로 못 박는다
.ct__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
column-gap: $sp-2;
}
.ct__row + .ct__row { border-top: 1px solid rgba(23, 20, 26, .06); }
.ct__ic {
grid-column: 1;
display: inline-flex;
color: rgba(23, 20, 26, .40);
}
.ct__row--stats .ct__ic, .ct__row--ads .ct__ic { color: $color; }
.ct__name {
grid-column: 2;
display: flex;
align-items: center;
gap: $sp-1;
min-width: 0;
border: 0;
margin: 0;
padding: $sp-2 0;
background: none;
color: $subject-ink;
font-family: inherit;
font-size: clamp(11px, 3vw, 13px);
font-weight: 700;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.ct__label { white-space: nowrap; }
.ct__caret {
display: inline-flex;
color: rgba(23, 20, 26, .36);
transform: rotate(0deg);
transition: transform $dur-quick $easing;
}
.ct__name[aria-expanded="true"] .ct__caret { transform: rotate(180deg); }
// ── 스위치: 노브는 translateX, 켜짐 색은 ::before 의 opacity ──
.ct__sw {
grid-column: 3;
position: relative;
width: 36px;
height: 20px;
flex: 0 0 auto;
border: 0;
margin: 0;
padding: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
cursor: pointer;
}
.ct__sw::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $color;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.ct__sw.is-on::before { opacity: 1; }
.ct__sw:focus-visible { outline: 2px solid rgba(47, 109, 246, .56); outline-offset: 2px; }
.ct__sw--fixed { cursor: not-allowed; opacity: .64; }
.ct__knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $stage-paper;
box-shadow: 0 2px 8px rgba(23, 20, 26, .24);
transform: translateX(0);
transition: transform $duration $easing;
}
.ct__sw.is-on .ct__knob { transform: translateX(16px); }
// ── 펼침: grid-template-rows 0fr → 1fr (height:auto 를 쓰지 않는다) ──
.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__desc {
margin: $sp-1 0;
padding: $sp-2;
border-radius: $r-sm;
background: rgba(47, 109, 246, .06);
color: rgba($stage-ink, .72);
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.4;
}
// ── 자동 루프: 통계가 펼쳐졌다 접히는 사이 마케팅 스위치가 켜졌다 꺼진다. 0% 와 100% 가 같다 ──
.ct.is-demo .ct__row--stats .ct__panel { animation: ct-open $dur-loop $easing infinite; }
.ct.is-demo .ct__row--stats .ct__caret { animation: ct-caret $dur-loop $easing infinite; }
.ct.is-demo .ct__row--ads .ct__knob { animation: ct-knob $dur-loop $easing infinite; }
.ct.is-demo .ct__row--ads .ct__sw::before { animation: ct-fill $dur-loop $easing infinite; }
@keyframes ct-open {
0% { grid-template-rows: 0fr; }
20%, 70% { grid-template-rows: 1fr; }
100% { grid-template-rows: 0fr; }
}
@keyframes ct-caret {
0% { transform: rotate(0deg); }
20%, 70% { transform: rotate(180deg); }
100% { transform: rotate(0deg); }
}
@keyframes ct-knob {
0% { transform: translateX(0); }
35%, 80% { transform: translateX(16px); }
100% { transform: translateX(0); }
}
@keyframes ct-fill {
0% { opacity: 0; }
35%, 80% { opacity: 1; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.ct { padding: $sp-3; gap: $sp-1; }
.ct__row { column-gap: $sp-1; }
.ct__name { padding: $sp-1 0; }
.ct__desc { margin: $sp-1 0; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ct.is-demo .ct__row--stats .ct__panel,
.ct.is-demo .ct__row--stats .ct__caret,
.ct.is-demo .ct__row--ads .ct__knob,
.ct.is-demo .ct__row--ads .ct__sw::before { animation: none !important; }
.ct__panel, .ct__caret, .ct__knob, .ct__sw::before { 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);
}
.ct {
box-sizing: border-box;
width: min(100%, 400px);
display: grid;
gap: 8px;
padding: 16px;
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);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ct__head {
margin: 0;
font-size: clamp(13px, 3.4vw, 15px);
font-weight: 700;
line-height: 1.2;
}
.ct__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
column-gap: 8px;
}
.ct__row + .ct__row {
border-top: 1px solid rgba(23, 20, 26, 0.06);
}
.ct__ic {
grid-column: 1;
display: inline-flex;
color: rgba(23, 20, 26, 0.4);
}
.ct__row--stats .ct__ic, .ct__row--ads .ct__ic {
color: #2f6df6;
}
.ct__name {
grid-column: 2;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
border: 0;
margin: 0;
padding: 8px 0;
background: none;
color: #17141a;
font-family: inherit;
font-size: clamp(11px, 3vw, 13px);
font-weight: 700;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.ct__label {
white-space: nowrap;
}
.ct__caret {
display: inline-flex;
color: rgba(23, 20, 26, 0.36);
transform: rotate(0deg);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__name[aria-expanded=true] .ct__caret {
transform: rotate(180deg);
}
.ct__sw {
grid-column: 3;
position: relative;
width: 36px;
height: 20px;
flex: 0 0 auto;
border: 0;
margin: 0;
padding: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ct__sw::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
opacity: 0;
transition: opacity 220ms ease-out;
}
.ct__sw.is-on::before {
opacity: 1;
}
.ct__sw:focus-visible {
outline: 2px solid rgba(47, 109, 246, 0.56);
outline-offset: 2px;
}
.ct__sw--fixed {
cursor: not-allowed;
opacity: 0.64;
}
.ct__knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 20, 26, 0.24);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__sw.is-on .ct__knob {
transform: translateX(16px);
}
.ct__panel {
grid-column: 1/-1;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ct__inner {
overflow: hidden;
}
.ct__row.is-open .ct__panel {
grid-template-rows: 1fr;
}
.ct__desc {
margin: 4px 0;
padding: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.06);
color: rgba(23, 20, 26, 0.72);
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.4;
}
.ct.is-demo .ct__row--stats .ct__panel {
animation: ct-open 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ct.is-demo .ct__row--stats .ct__caret {
animation: ct-caret 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ct.is-demo .ct__row--ads .ct__knob {
animation: ct-knob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ct.is-demo .ct__row--ads .ct__sw::before {
animation: ct-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ct-open {
0% {
grid-template-rows: 0fr;
}
20%, 70% {
grid-template-rows: 1fr;
}
100% {
grid-template-rows: 0fr;
}
}
@keyframes ct-caret {
0% {
transform: rotate(0deg);
}
20%, 70% {
transform: rotate(180deg);
}
100% {
transform: rotate(0deg);
}
}
@keyframes ct-knob {
0% {
transform: translateX(0);
}
35%, 80% {
transform: translateX(16px);
}
100% {
transform: translateX(0);
}
}
@keyframes ct-fill {
0% {
opacity: 0;
}
35%, 80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ct {
padding: 12px;
gap: 4px;
}
.ct__row {
column-gap: 4px;
}
.ct__name {
padding: 4px 0;
}
.ct__desc {
margin: 4px 0;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ct.is-demo .ct__row--stats .ct__panel,
.ct.is-demo .ct__row--stats .ct__caret,
.ct.is-demo .ct__row--ads .ct__knob,
.ct.is-demo .ct__row--ads .ct__sw::before {
animation: none !important;
}
.ct__panel, .ct__caret, .ct__knob, .ct__sw::before {
transition: none;
}
}// 실제 동작: 스위치는 role="switch" 로 진짜 토글되고, 행 제목은 aria-expanded 로 설명을 편다.
// 필수는 항상 켬이라 눌러도 바뀌지 않는다. 선택은 localStorage 에 남는다.
var ct = document.querySelector('.ct');
var KEY = 'gd-consent-02';
var state = { stats: true, ads: false };
function read() {
try { var s = localStorage.getItem(KEY); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
function write() {
try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {}
}
function paint() {
var rows = ct.querySelectorAll('.ct__row');
for (var i = 0; i < rows.length; i++) {
var k = rows[i].getAttribute('data-k');
if (k === 'need') continue;
var on = !!state[k];
var sw = rows[i].querySelector('.ct__sw');
sw.setAttribute('aria-checked', on ? 'true' : 'false');
sw.classList.toggle('is-on', on);
}
}
function stop() { ct.classList.remove('is-demo'); }
var sws = ct.querySelectorAll('.ct__sw');
for (var i = 0; i < sws.length; i++) {
sws[i].addEventListener('click', function (e) {
stop();
var k = e.currentTarget.closest('.ct__row').getAttribute('data-k');
if (k === 'need') return;
state[k] = !state[k];
paint();
write();
});
}
var names = ct.querySelectorAll('.ct__name');
for (var j = 0; j < names.length; j++) {
names[j].addEventListener('click', function (e) {
stop();
var b = e.currentTarget;
var open = b.getAttribute('aria-expanded') === 'true';
b.setAttribute('aria-expanded', open ? 'false' : 'true');
b.closest('.ct__row').classList.toggle('is-open', !open);
});
}
var saved = read();
if (saved) { state.stats = !!saved.stats; state.ads = !!saved.ads; }
paint();import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CategoryTogglePanel.module.scss";
export type CategoryTogglePanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const KEY = "gd-consent-02";
function Caret() {
return (
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
</span>
);
}
export default function CategoryTogglePanel({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CategoryTogglePanelProps) {
const [stats, setStats] = useState<boolean>(true);
const [ads, setAds] = useState<boolean>(false);
const [open, setOpen] = useState<string>("");
const [live, setLive] = useState<boolean>(true);
useEffect(() => {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const v = JSON.parse(raw) as { stats?: boolean; ads?: boolean };
setStats(!!v.stats);
setAds(!!v.ads);
}
} catch (e) {
/* 샌드박스에서 던진다 */
}
}, []);
function save(nextStats: boolean, nextAds: boolean): void {
try {
localStorage.setItem(KEY, JSON.stringify({ stats: nextStats, ads: nextAds }));
} catch (e) {
/* 샌드박스에서 던진다 */
}
}
function toggleOpen(id: string): void {
setLive(false);
setOpen(open === id ? "" : id);
}
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={`${styles.ct} ${live ? styles.isDemo : ""}`} style={vars}>
<h2 className={styles.head}>Cookie settings</h2>
<div className={styles.row}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16">
<g fill="currentColor">
<path d="M216 56v56c0 96-88 120-88 120s-88-24-88-120V56a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-21.81C61.82 179.51 48 149.3 48 112V56h160ZM82.34 141.66a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0Z" />
</g>
</svg>
</span>
<button
className={styles.name}
type="button"
aria-expanded={open === "p1"}
aria-controls="ct-p1"
onClick={() => toggleOpen("p1")}
>
<span className={styles.label}>Necessary</span>
<Caret />
</button>
<button
className={`${styles.sw} ${styles.swFixed} ${styles.isOn}`}
type="button"
role="switch"
aria-checked={true}
aria-disabled={true}
aria-label="Necessary cookies"
>
<span className={styles.knob} />
</button>
<div className={`${styles.panel} ${open === "p1" ? styles.isOpen : ""}`} id="ct-p1">
<div className={styles.inner}>
<p className={styles.desc}>Required for the site to load at all.</p>
</div>
</div>
</div>
<div className={`${styles.row} ${styles.rowStats}`}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16">
<g fill="currentColor">
<path d="M208 40v168h-56V40Z" opacity=".2" />
<path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z" />
</g>
</svg>
</span>
<button
className={styles.name}
type="button"
aria-expanded={open === "p2"}
aria-controls="ct-p2"
onClick={() => toggleOpen("p2")}
>
<span className={styles.label}>Analytics</span>
<Caret />
</button>
<button
className={`${styles.sw} ${stats ? styles.isOn : ""}`}
type="button"
role="switch"
aria-checked={stats}
aria-label="Analytics cookies"
onClick={() => {
setLive(false);
setStats(!stats);
save(!stats, ads);
}}
>
<span className={styles.knob} />
</button>
<div className={`${styles.panel} ${open === "p2" ? styles.isOpen : ""}`} id="ct-p2">
<div className={styles.inner}>
<p className={styles.desc}>Counts which posts get read, and how far.</p>
</div>
</div>
</div>
<div className={`${styles.row} ${styles.rowAds}`}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16">
<g fill="currentColor">
<path d="M240 120a40 40 0 0 1-40 40h-40V80h40a40 40 0 0 1 40 40" opacity=".2" />
<path d="M248 120a48.05 48.05 0 0 0-48-48h-39.8c-2.91-.17-53.62-3.74-101.91-44.24A16 16 0 0 0 32 40v160a16 16 0 0 0 26.29 12.25c37.77-31.68 77-40.76 93.71-43.3v31.72a16 16 0 0 0 7.12 13.33l11 7.33A16 16 0 0 0 194.5 212l11.77-44.36A48.07 48.07 0 0 0 248 120M48 199.93V40c42.81 35.91 86.63 45 104 47.24v65.48c-17.35 2.28-61.16 11.35-104 47.21m131 8v.11l-11-7.33V168h21.6ZM200 152h-32V88h32a32 32 0 1 1 0 64" />
</g>
</svg>
</span>
<button
className={styles.name}
type="button"
aria-expanded={open === "p3"}
aria-controls="ct-p3"
onClick={() => toggleOpen("p3")}
>
<span className={styles.label}>Marketing</span>
<Caret />
</button>
<button
className={`${styles.sw} ${ads ? styles.isOn : ""}`}
type="button"
role="switch"
aria-checked={ads}
aria-label="Marketing cookies"
onClick={() => {
setLive(false);
setAds(!ads);
save(stats, !ads);
}}
>
<span className={styles.knob} />
</button>
<div className={`${styles.panel} ${open === "p3" ? styles.isOpen : ""}`} id="ct-p3">
<div className={styles.inner}>
<p className={styles.desc}>Shows ads picked from your interests.</p>
</div>
</div>
</div>
</div>
);
}02 Category toggle panel — Measured area 17.019% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Cookie Consent Banner UI — 9 Patterns to Paste In
The post this demo lives in
Cookie Consent Banner UI — 9 Patterns to Paste InA cookie consent banner ui is the strip or card that tells visitors a site stores cookies and records their choice. Thes See all demos in this post


