Exclusivobeta
Cookie Consent <mv-cookie-consent>
Banner de cookies no modal con Accept all y Reject all del mismo peso y un panel Choose, guardado con una versión de la política en localStorage y en una cookie propia; a diferencia de la mayoría de los banners, bloquea de verdad los scripts: <script type="text/plain" data-consent="analytics"> y los embeds data-src solo se ejecutan tras el consentimiento, una vez, en orden, conservando el nonce CSP.
| Categoría | Superposiciones |
|---|---|
| Tipo | Web Component (<mv-cookie-consent>) |
| Estado | beta |
| Keywords | exclusive, light, cookie, consent, gdpr, privacy, banner, gpc, script-gating |
When to use
- A site with EU or UK visitors must ask before loading analytics, ads or embeds, without a heavy third-party consent script
- Tracking scripts should stay inert until the visitor opts in, and a footer Cookie settings link must reopen the choices
- The privacy policy changed and every visitor has to be asked again
Avoid when
- The message is a one-off notice or confirmation, not a consent choice → use Toast instead
- Consent must be logged server-side for audits or shared across devices and domains; this stores it in the browser only
- The site sets no optional cookies and loads no third-party trackers, so no banner is needed
Instalación
node scripts/add.mjs cookie-consent --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["cookie-consent"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/observe.js, core/presence.js, components/cookie-consent/cookie-consent.js, components/cookie-consent/cookie-consent.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-cookie-consent categories="analytics, marketing" version="1"></mv-cookie-consent>
<script type="text/plain" data-consent="analytics" src="/analytics.js"></script>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="mv-cc-demo" style="position:relative;width:100%;max-width:760px;min-height:600px;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-bg);overflow:hidden;display:flex;flex-direction:column">
<header style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 1.1rem;border-bottom:1px solid var(--mv-border)">
<strong style="display:flex;align-items:center;gap:.5rem;font-size:.95rem">
<span style="width:1.4rem;height:1.4rem;border-radius:.4rem;background:linear-gradient(135deg,var(--mv-accent),var(--mv-accent-fg))"></span>
Northwind Travel
</strong>
<nav style="display:flex;gap:1rem;font-size:.85rem;color:var(--mv-fg-muted)"><span>Trips</span><span>Journal</span><span>Help</span></nav>
</header>
<main style="flex:1;display:grid;gap:1rem;padding:1.4rem 1.1rem;align-content:start">
<div>
<p style="margin:0 0 .25rem;font-size:.75rem;color:var(--mv-fg-subtle)">Kyoto · 6 nights · from $1,240</p>
<h3 style="margin:0;font-size:1.25rem">Autumn in the Higashiyama hills</h3>
<p style="margin:.4rem 0 0;max-width:46ch;font-size:.9rem;color:var(--mv-fg-muted)">Temple gardens at first light, a tea lesson in Gion and a night train to Kanazawa. Departs October 18, 2026.</p>
</div>
<dl style="display:grid;grid-template-columns:auto 1fr;gap:.35rem 1rem;margin:0;justify-self:end;padding:.75rem 1rem;border:1px dashed var(--mv-border-strong);border-radius:var(--mv-radius-lg);font-size:.8rem">
<dt style="color:var(--mv-fg-subtle)">Analytics script</dt><dd id="mv-cc-demo-analytics" style="margin:0;font-weight:500">Blocked</dd>
<dt style="color:var(--mv-fg-subtle)">Marketing pixel</dt><dd id="mv-cc-demo-marketing" style="margin:0;font-weight:500">Blocked</dd>
<dt style="color:var(--mv-fg-subtle)">Last choice</dt><dd id="mv-cc-demo-last" style="margin:0;color:var(--mv-fg-muted)">None yet</dd>
</dl>
</main>
<footer style="display:flex;align-items:center;justify-content:flex-end;gap:1rem;padding:.6rem 1.1rem;border-top:1px solid var(--mv-border);font-size:.8rem;color:var(--mv-fg-muted)">
<a href="#" data-consent-open style="color:inherit">Cookie settings</a>
<button class="mv-button" data-variant="ghost" data-size="sm" id="mv-cc-demo-reset" type="button">Forget my choice</button>
</footer>
<mv-cookie-consent contained categories="analytics, marketing" version="2" storage-key="northwind-consent" policy="#privacy">
<p data-category="analytics" data-label="Analytics">Anonymous visit counts, hosted in the EU. Helps us see which trips people read.</p>
</mv-cookie-consent>
</div>
<!-- Inert until the visitor allows the category, then run once. -->
<script type="text/plain" data-consent="analytics">
document.getElementById("mv-cc-demo-analytics").textContent = "Loaded";
</script>
<script type="text/plain" data-consent="marketing">
document.getElementById("mv-cc-demo-marketing").textContent = "Loaded";
</script>
<script type="module">
const root = document.getElementById("mv-cc-demo");
const last = document.getElementById("mv-cc-demo-last");
const fmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
root.addEventListener("mv-consent", (e) => {
const on = Object.entries(e.detail.categories).filter(([id, v]) => v && id !== "necessary").map(([id]) => id);
last.textContent = `${e.detail.method} · ${on.length ? on.join(", ") : "necessary only"} · ${fmt.format(e.detail.date)}`;
});
document.getElementById("mv-cc-demo-reset").addEventListener("click", async () => {
await customElements.whenDefined("mv-cookie-consent");
customElements.get("mv-cookie-consent").reset();
last.textContent = "None yet";
});
</script>API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
categories | string (comma or space list) | analytics, marketing | Optional categories the site uses. "necessary" is always on and always listed. Without the attribute, the ids of the category child elements, or analytics and marketing. |
version | string | 1 | Policy version stored with the choice. Changing it asks every visitor again (their previous switches are pre-filled). Adding a category also asks again. |
storage-key | string | mv-consent | localStorage key and first-party cookie name. The cookie reads v=<version>&at=<ms>&analytics=1&marketing=0, so the server can read it too. |
policy | URL | Adds a “Privacy policy” link under the message. | |
contained | boolean | Positions the banner at the bottom-start of the nearest positioned ancestor instead of the viewport (previews, app shells). | |
data-consent (on any script, iframe, img…) | category list | <script type="text/plain" data-consent="analytics"> (inline or src) is re-created as a real script once every listed category is granted; data-type="module" sets the final type. An element with data-consent and data-src gets its src. Elements added later (SPA) are handled too. | |
data-consent-open (on any element) | boolean | Clicking it reopens the banner with the choices panel, e.g. a footer “Cookie settings” link. Focus returns to it after saving. |
Properties
| Name | Tipo | Description |
|---|---|---|
strings | object | Every visible and announced text: heading, message, accept, reject, choose, save, policy, panel, alwaysOn, gpc, saved, and categories: { id: { label, description } }. Merged with the English defaults (DEFAULT_STRINGS export). |
gpc | boolean (read-only) | True when the browser sends Global Privacy Control. |
Methods
| Name | Description |
|---|---|
MvCookieConsent.get() | Static. The current valid choice { version, date, categories: { necessary: true, analytics: false, … } }, or null when the visitor has not decided or must be asked again. |
MvCookieConsent.has(category) | Static. true when the category is granted ("necessary" is always true). |
MvCookieConsent.open(opener?) / open(opener?) | Shows the banner with the choices panel open and moves focus to the first switch. |
MvCookieConsent.reset() | Static. Forgets the stored choice and shows the banner again (scripts that already ran stay until the page reloads). |
accept() / reject() / save() | Same as the three buttons; save() stores the switches of the panel. |
Events
| Name | Description |
|---|---|
mv-consent | After each choice. detail = { version, date, categories, method: "accept" | "reject" | "custom", revoked: string[], gpc }. revoked lists categories that were granted before: scripts that already ran cannot be unloaded, so reload the page if you need them gone. |
Content structure
| Name | Description |
|---|---|
(children) | Your own message (paragraphs, links) replaces the default text. |
[data-category="id"][data-label] | A child element per category: data-label is the name, its content the description (overrides strings.categories). |
CSS classes
| Name | Description |
|---|---|
mv-cookie-consent-card / -title / -message / -policy / -gpc / -panel / -list / -item / -row / -name / -desc / -always / -switch / -actions / -button / -choose | Generated parts, all stylable. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-cookie-consent-width | 25rem | Banner width (capped by the viewport). |
--mv-cookie-consent-offset | var(--mv-space-4) | Distance from the bottom and start edges. |
--mv-cookie-consent-button-bg | 6% fg over the card | Background of Accept all, Reject all and Save choices. One variable for all three, so the two answers always weigh the same. |
--mv-cookie-consent-button-fg | var(--mv-fg) | Text color of those buttons. |
Accessibility
A non-modal region labelled by its heading (h2): no backdrop, no focus trap, page stays usable, focus is never taken on load. Place the tag early in <body> so keyboard and screen reader users meet it first. Accept all and Reject all are identical buttons side by side, Reject never hidden behind Choose. Choose is a disclosure (aria-expanded, aria-controls); opening it moves focus to the first switch; Escape closes it and returns focus to Choose. When reopened from a data-consent-open link, Escape closes the banner and focus goes back to the link, also after saving. Category switches are native checkboxes with role=switch, labelled by the name and described by the description; necessary shows “Always on” as text. After a choice a polite status says “Your cookie choices were saved.” once. Optional switches start off (opt-in). Global Privacy Control: when navigator.globalPrivacyControl is true, Accept all leaves marketing (ids marketing, advertising, ads) off and a note says so; the visitor can still turn it on in Choose. Forced colors: system borders, native checkboxes, Highlight outlines. Reduced motion: no slide, instant fade. This component is a technical tool, not legal advice: the categories, texts and retention (about six months) must match your own policy.