diff --git a/packages/cookie-banner/src/activation.ts b/packages/cookie-banner/src/activation.ts
index e3bb899dc..47c5754d9 100644
--- a/packages/cookie-banner/src/activation.ts
+++ b/packages/cookie-banner/src/activation.ts
@@ -12,6 +12,8 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
+import type { BannerTexts } from "./i18n";
+import { interpolate } from "./i18n";
import { removeCookies } from "./cookie-utils";
import { LOCK_ICON } from "./html";
@@ -111,6 +113,7 @@ function createPlaceholder(
el: Element,
category: string,
label?: string,
+ texts?: BannerTexts,
): void {
if (el.hasAttribute(ATTR_HIDDEN)) return;
@@ -152,10 +155,15 @@ function createPlaceholder(
}
}
+ const phText = texts?.placeholder_text
+ ? interpolate(texts.placeholder_text, { category: escapeHtml(displayLabel) })
+ : `This content requires ${escapeHtml(displayLabel)} cookies.`;
+ const phButton = texts?.placeholder_button ?? "Manage cookie preferences";
+
placeholder.innerHTML = [
`${LOCK_ICON}`,
- `
This content requires ${escapeHtml(displayLabel)} cookies.
`,
- ``,
+ `${phText}
`,
+ ``,
].join("");
placeholder.querySelector(".probo-ph-link")!.addEventListener("click", () => {
@@ -362,13 +370,14 @@ export function activateElements(
export function addPlaceholders(
consentData: Record,
categoryLabels: Record,
+ texts?: BannerTexts,
): void {
const elements = document.querySelectorAll(`[${ATTR_CATEGORY}]`);
for (const el of elements) {
const category = el.getAttribute(ATTR_CATEGORY);
if (!category || consentData[category]) continue;
if (!VISUAL_TAGS.has(el.tagName)) continue;
- createPlaceholder(el, category, categoryLabels[category]);
+ createPlaceholder(el, category, categoryLabels[category], texts);
}
}
@@ -376,16 +385,18 @@ function tryPlaceholder(
el: Element,
consentData: Record,
categoryLabels: Record,
+ texts?: BannerTexts,
): void {
const category = el.getAttribute(ATTR_CATEGORY);
if (!category || consentData[category]) return;
if (!VISUAL_TAGS.has(el.tagName)) return;
- createPlaceholder(el, category, categoryLabels[category]);
+ createPlaceholder(el, category, categoryLabels[category], texts);
}
export function observeAndActivate(
consentData: Record,
categoryLabels: Record,
+ texts?: BannerTexts,
): MutationObserver {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
@@ -396,13 +407,13 @@ export function observeAndActivate(
if (node.hasAttribute(ATTR_CATEGORY)) {
tryActivate(node, consentData);
- tryPlaceholder(node, consentData, categoryLabels);
+ tryPlaceholder(node, consentData, categoryLabels, texts);
}
const nested = node.querySelectorAll(`[${ATTR_CATEGORY}]`);
for (const el of nested) {
tryActivate(el, consentData);
- tryPlaceholder(el, consentData, categoryLabels);
+ tryPlaceholder(el, consentData, categoryLabels, texts);
}
}
}
diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts
index ddb5503fb..de6bf7b54 100644
--- a/packages/cookie-banner/src/client.ts
+++ b/packages/cookie-banner/src/client.ts
@@ -22,9 +22,14 @@ import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
import { CookieDetector } from "./detector";
import { NotFoundError } from "./errors";
import { fetchJSON } from "./http";
+import type { BannerTexts } from "./i18n";
+import { detectLanguage } from "./i18n";
import { enqueue, flush } from "./queue";
import { getOrCreateVisitorId } from "./visitor";
+export type { BannerTexts } from "./i18n";
+export { interpolate } from "./i18n";
+
export interface CookieItem {
name: string;
duration: string;
@@ -41,11 +46,15 @@ export interface Category {
export interface BannerConfig {
banner_id: string;
version: number;
+ language: string;
+ default_language: string;
+ available_languages: string[];
privacy_policy_url: string;
consent_expiry_days: number;
consent_mode: "OPT_IN" | "OPT_OUT";
show_branding: boolean;
categories: Category[];
+ texts: BannerTexts;
}
export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC";
@@ -68,12 +77,14 @@ export interface ConsentRecord {
export interface CookieBannerClientOptions {
bannerId: string;
baseUrl: string;
+ lang?: string;
}
export class CookieBannerClient {
private readonly baseUrl: URL;
private readonly bannerId: string;
private readonly visitorId: string;
+ private readonly lang: string;
private bannerConfig: BannerConfig | null = null;
private consent: VisitorConsent | null = null;
@@ -88,10 +99,14 @@ export class CookieBannerClient {
this.baseUrl = new URL(base);
this.bannerId = config.bannerId;
this.visitorId = getOrCreateVisitorId(config.bannerId);
+ this.lang = detectLanguage(config.lang);
}
async load(): Promise {
const configUrl = new URL(`${this.bannerId}/config`, this.baseUrl);
+ if (this.lang) {
+ configUrl.searchParams.set("lang", this.lang);
+ }
const config = await fetchJSON(configUrl);
this.bannerConfig = config;
@@ -237,13 +252,14 @@ export class CookieBannerClient {
categoryLabels[cat.name] = cat.name;
}
+ const texts = this.config.texts;
deactivateElements(consentData, categoryCookies, categoryLabels);
activateElements(consentData);
- addPlaceholders(consentData, categoryLabels);
+ addPlaceholders(consentData, categoryLabels, texts);
if (this.observer) {
this.observer.disconnect();
}
- this.observer = observeAndActivate(consentData, categoryLabels);
+ this.observer = observeAndActivate(consentData, categoryLabels, texts);
}
private startDetector(config: BannerConfig): void {
diff --git a/packages/cookie-banner/src/components/cookie-banner-root.ts b/packages/cookie-banner/src/components/cookie-banner-root.ts
index 08462bd48..127d612ef 100644
--- a/packages/cookie-banner/src/components/cookie-banner-root.ts
+++ b/packages/cookie-banner/src/components/cookie-banner-root.ts
@@ -24,7 +24,7 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
private _draft: ConsentDraft = {};
static get observedAttributes(): string[] {
- return ["banner-id", "base-url", "reopen-widget"];
+ return ["banner-id", "base-url", "reopen-widget", "lang"];
}
get client(): CookieBannerClient {
@@ -129,7 +129,9 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
return;
}
- this._client = new CookieBannerClient({ bannerId, baseUrl });
+ const lang = this.getAttribute("lang") ?? undefined;
+
+ this._client = new CookieBannerClient({ bannerId, baseUrl, lang });
try {
await this._client.load();
diff --git a/packages/cookie-banner/src/components/cookie-list.ts b/packages/cookie-banner/src/components/cookie-list.ts
index adec94e70..29b170784 100644
--- a/packages/cookie-banner/src/components/cookie-list.ts
+++ b/packages/cookie-banner/src/components/cookie-list.ts
@@ -13,8 +13,10 @@
// PERFORMANCE OF THIS SOFTWARE.
import type { CookieItem } from "../client";
+import { type BannerTexts, interpolate } from "../i18n";
import { ProboElement } from "./base";
import type { ProboCategory } from "./category";
+import type { ProboCookieBannerRoot } from "./cookie-banner-root";
export class ProboCookieList extends ProboElement {
private category: ProboCategory | null = null;
@@ -35,13 +37,16 @@ export class ProboCookieList extends ProboElement {
private stamp(): void {
if (!this.template || !this.category) return;
+ const root = this.findAncestor("probo-cookie-banner-root");
+ const texts = root?.bannerConfig?.texts;
+
const cookies = this.category.cookies;
for (const cookie of cookies) {
- this.stampCookie(cookie);
+ this.stampCookie(cookie, texts);
}
}
- private stampCookie(cookie: CookieItem): void {
+ private stampCookie(cookie: CookieItem, texts?: BannerTexts): void {
if (!this.template) return;
const wrapper = document.createElement("probo-cookie");
@@ -52,6 +57,10 @@ export class ProboCookieList extends ProboElement {
duration: cookie.duration,
description: cookie.description,
});
+ this.fillLabels(clone, texts, {
+ description: cookie.description,
+ duration: cookie.duration,
+ });
wrapper.appendChild(clone);
this.appendChild(wrapper);
@@ -68,6 +77,25 @@ export class ProboCookieList extends ProboElement {
}
}
}
+
+ private fillLabels(
+ fragment: DocumentFragment,
+ texts: BannerTexts | undefined,
+ values: Record,
+ ): void {
+ const labels = fragment.querySelectorAll("[data-label]");
+ for (const el of labels) {
+ const key = el.getAttribute("data-label")!;
+ const slotName = key.replace("label_", "");
+ const value = values[slotName] ?? "";
+ const tpl = texts?.[key];
+ if (tpl) {
+ el.textContent = interpolate(tpl, { value });
+ const slot = el.querySelector(`[data-slot="${slotName}"]`);
+ if (slot) slot.remove();
+ }
+ }
+ }
}
export class ProboCookie extends ProboElement {}
diff --git a/packages/cookie-banner/src/components/settings-button.ts b/packages/cookie-banner/src/components/settings-button.ts
index 70e22bb0c..551109960 100644
--- a/packages/cookie-banner/src/components/settings-button.ts
+++ b/packages/cookie-banner/src/components/settings-button.ts
@@ -26,7 +26,7 @@ export class ProboSettingsButton extends HTMLElement {
}
static get observedAttributes(): string[] {
- return ["position"];
+ return ["position", "aria-settings-label"];
}
private get position(): string {
@@ -89,6 +89,11 @@ export class ProboSettingsButton extends HTMLElement {
const btn = this.shadow.querySelector("button");
btn?.addEventListener("click", this.handleClick);
+
+ const ariaLabel = this.getAttribute("aria-settings-label");
+ if (ariaLabel && btn) {
+ btn.setAttribute("aria-label", ariaLabel);
+ }
}
disconnectedCallback(): void {
diff --git a/packages/cookie-banner/src/cookie-utils.ts b/packages/cookie-banner/src/cookie-utils.ts
index 218eac380..07018be43 100644
--- a/packages/cookie-banner/src/cookie-utils.ts
+++ b/packages/cookie-banner/src/cookie-utils.ts
@@ -12,25 +12,28 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
-// [unitSeconds, singular, plural, snapBuffer]
+import type { BannerTexts } from "./i18n";
+import { interpolate } from "./i18n";
+
+// [unitSeconds, textKey, snapBuffer]
// snapBuffer: if the remainder is within this many seconds of the next
// whole unit, round up instead of carrying into smaller units.
-const DURATION_UNITS: [number, string, string, number][] = [
- [365 * 24 * 3600, "year", "years", 21 * 24 * 3600],
- [30 * 24 * 3600, "month", "months", 2 * 24 * 3600],
- [7 * 24 * 3600, "week", "weeks", 12 * 3600],
- [24 * 3600, "day", "days", 0],
- [3600, "hour", "hours", 5 * 60],
- [60, "minute", "minutes", 15],
+const DURATION_UNITS: [number, string, number][] = [
+ [365 * 24 * 3600, "duration_year", 21 * 24 * 3600],
+ [30 * 24 * 3600, "duration_month", 2 * 24 * 3600],
+ [7 * 24 * 3600, "duration_week", 12 * 3600],
+ [24 * 3600, "duration_day", 0],
+ [3600, "duration_hour", 5 * 60],
+ [60, "duration_minute", 15],
];
-function humanizeDuration(seconds: number): string {
- if (seconds <= 0) return "session";
+function humanizeDuration(seconds: number, texts?: BannerTexts): string {
+ if (seconds <= 0) return texts?.duration_session ?? "session";
let remaining = seconds;
const parts: string[] = [];
- for (const [unit, singular, plural, snap] of DURATION_UNITS) {
+ for (const [unit, key, snap] of DURATION_UNITS) {
if (remaining >= unit) {
let count = Math.floor(remaining / unit);
const leftover = remaining - count * unit;
@@ -44,11 +47,13 @@ function humanizeDuration(seconds: number): string {
remaining = leftover;
}
- parts.push(count === 1 ? `1 ${singular}` : `${count} ${plural}`);
+ const tplKey = count === 1 ? `${key}_one` : `${key}_other`;
+ const tpl = texts?.[tplKey] ?? (count === 1 ? `{{count}} ${key.replace("duration_", "")}` : `{{count}} ${key.replace("duration_", "")}s`);
+ parts.push(interpolate(tpl, { count: String(count) }));
}
}
- return parts.length > 0 ? parts.join(", ") : "session";
+ return parts.length > 0 ? parts.join(", ") : texts?.duration_session ?? "session";
}
export function parseCookieName(raw: string): string {
@@ -57,15 +62,16 @@ export function parseCookieName(raw: string): string {
return raw.substring(0, eqIdx).trim();
}
-export function parseDuration(raw: string): string {
+export function parseDuration(raw: string, texts?: BannerTexts): string {
+ const session = texts?.duration_session ?? "session";
const parts = raw.split(";").map((s) => s.trim());
for (const part of parts) {
const lower = part.toLowerCase();
if (lower.startsWith("max-age=")) {
const val = parseInt(part.substring(8), 10);
- if (isNaN(val) || val <= 0) return "session";
- return humanizeDuration(val);
+ if (isNaN(val) || val <= 0) return session;
+ return humanizeDuration(val, texts);
}
}
@@ -74,16 +80,16 @@ export function parseDuration(raw: string): string {
if (lower.startsWith("expires=")) {
const dateStr = part.substring(8);
const expires = new Date(dateStr);
- if (isNaN(expires.getTime())) return "session";
+ if (isNaN(expires.getTime())) return session;
const deltaSeconds = Math.round(
(expires.getTime() - Date.now()) / 1000,
);
- if (deltaSeconds <= 0) return "session";
- return humanizeDuration(deltaSeconds);
+ if (deltaSeconds <= 0) return session;
+ return humanizeDuration(deltaSeconds, texts);
}
}
- return "session";
+ return session;
}
export function isDeletion(raw: string): boolean {
diff --git a/packages/cookie-banner/src/i18n.ts b/packages/cookie-banner/src/i18n.ts
new file mode 100644
index 000000000..fd3bd0953
--- /dev/null
+++ b/packages/cookie-banner/src/i18n.ts
@@ -0,0 +1,37 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+export interface BannerTexts {
+ [key: string]: string;
+}
+
+export function detectLanguage(explicit?: string): string {
+ if (explicit) return explicit;
+
+ const htmlLang = document.documentElement.lang;
+ if (htmlLang) return htmlLang.split("-")[0];
+
+ if (typeof navigator !== "undefined" && navigator.language) {
+ return navigator.language.split("-")[0];
+ }
+
+ return "";
+}
+
+export function interpolate(
+ template: string,
+ vars: Record,
+): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
+}
diff --git a/packages/cookie-banner/src/themed-banner/iife.ts b/packages/cookie-banner/src/themed-banner/iife.ts
index 1a0c283ba..2f0e9a287 100644
--- a/packages/cookie-banner/src/themed-banner/iife.ts
+++ b/packages/cookie-banner/src/themed-banner/iife.ts
@@ -38,6 +38,11 @@ if (script) {
el.setAttribute("reopen-widget", reopenWidget);
}
+ const lang = script.getAttribute("data-lang");
+ if (lang) {
+ el.setAttribute("lang", lang);
+ }
+
document.body.appendChild(el);
};
diff --git a/packages/cookie-banner/src/themed-banner/themed-banner.ts b/packages/cookie-banner/src/themed-banner/themed-banner.ts
index f9c603072..9e2b3d250 100644
--- a/packages/cookie-banner/src/themed-banner/themed-banner.ts
+++ b/packages/cookie-banner/src/themed-banner/themed-banner.ts
@@ -15,6 +15,7 @@
import { registerComponents } from "../components";
import type { ProboCookieBannerRoot } from "../components/cookie-banner-root";
import type { BannerConfig } from "../client";
+import { interpolate } from "../i18n";
import { BRANDING, CHEVRON_DOWN, CLOSE_ICON } from "../html";
import { THEMED_STYLES } from "./styles";
@@ -27,7 +28,7 @@ export class ProboThemedBanner extends HTMLElement {
}
static get observedAttributes(): string[] {
- return ["banner-id", "base-url", "reopen-widget"];
+ return ["banner-id", "base-url", "reopen-widget", "lang"];
}
connectedCallback(): void {
@@ -44,21 +45,21 @@ export class ProboThemedBanner extends HTMLElement {
const position = this.getAttribute("position") ?? "bottom-left";
const reopenWidget = this.getAttribute("reopen-widget");
const reopenAttr = reopenWidget ? ` reopen-widget="${this.esc(reopenWidget)}"` : "";
+ const lang = this.getAttribute("lang");
+ const langAttr = lang ? ` lang="${this.esc(lang)}"` : "";
this.shadow.innerHTML = `
-
+
-
Cookie Preferences
-
- We use cookies to improve your experience and analyze site traffic.
-
+
+
${BRANDING}
@@ -70,16 +71,16 @@ export class ProboThemedBanner extends HTMLElement {