document.head.insertAdjacentHTML("afterbegin", ` `); const fontIsolation = (async () => { const PAGY_WAND_FONT_CACHE_KEY = "pagy-wand-processed-fonts"; const cachedDataJson = sessionStorage.getItem(PAGY_WAND_FONT_CACHE_KEY); if (cachedDataJson) { try { const cachedData = JSON.parse(cachedDataJson); if (typeof cachedData.fontFaceRules === "string" && typeof cachedData.processedComponentCss === "string") { if (cachedData.fontFaceRules) { const styleEl = document.createElement("style"); styleEl.id = "pagy-wand-font-css"; styleEl.textContent = cachedData.fontFaceRules; if (document.head) { document.head.appendChild(styleEl); } else { document.addEventListener("DOMContentLoaded", () => document.head.appendChild(styleEl), { once: true }); } } return { processedComponentCss: cachedData.processedComponentCss }; } else { console.warn("Pagy Wand: Invalid cached font data structure found. Refetching."); sessionStorage.removeItem(PAGY_WAND_FONT_CACHE_KEY); } } catch (e) { console.error("Pagy Wand: Failed to parse cached font data. Refetching.", e); sessionStorage.removeItem(PAGY_WAND_FONT_CACHE_KEY); } } const icons = "check_circle,content_copy,error,help,tune,visibility,visibility_off"; const fontDefinitions = [ { url: "https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&family=Ubuntu+Sans+Mono:ital,wght@0,400..700;1,400..700&display=swap", fontMappings: [ { original: "Nunito Sans", custom: "PagyWand-Sans" }, { original: "Ubuntu Sans Mono", custom: "PagyWand-Mono" } ] }, { url: "https://fonts.googleapis.com/css2?family=Pattaya&text=PagyWand&display=swap", fontMappings: [ { original: "Pattaya", custom: "PagyWand-Logo" } ] }, { url: `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,GRAD@20..48,300,-50..200&icon_names=${icons}&display=block`, fontMappings: [ { original: "Material Symbols Rounded", custom: "PagyWand-Symbols" } ], classMappings: [ { original: "material-symbols-rounded", custom: "pagy-wand-symbol" } ] } ]; async function processGoogleFontCSS(fontCssUrl, fontMappings = [], classMappings = []) { const result = { fontFaceRules: "", componentCss: "" }; try { const response = await fetch(fontCssUrl); if (!response.ok) throw new Error(`Font CSS fetch failed (${response.status})`); let cssText = await response.text(); fontMappings.forEach((mapping) => { const regex = new RegExp(`(font-family:\\s*['"]?)${mapping.original}(['"]?;?)`, "g"); cssText = cssText.replace(regex, `$1${mapping.custom}$2`); }); classMappings.forEach((mapping) => { const regex = new RegExp(`\\.(${mapping.original})(?=[\\s\\.{,:])`, "g"); cssText = cssText.replace(regex, `.${mapping.custom}`); }); const fontFaceRegex = /(?:\/\*[\s\S]*?\*\/[\s\n]*)?@font-face\s*\{[^}]*}/g; const fontFaceMatches = cssText.match(fontFaceRegex); result.fontFaceRules = fontFaceMatches ? fontFaceMatches.join(` `) : ""; result.componentCss = cssText.replace(fontFaceRegex, "").trim(); } catch (error) { console.error("Failed to process Google Font CSS:", fontCssUrl, error); throw error; } return result; } const processedResults = await Promise.all(fontDefinitions.map((def) => processGoogleFontCSS(def.url, def.fontMappings, def.classMappings))); const allFontFaceRules = processedResults.map((r) => r.fontFaceRules).join(` `); const allComponentCss = processedResults.map((r) => r.componentCss).join(` `); if (allFontFaceRules) { const styleEl = document.createElement("style"); styleEl.id = "pagy-wand-font-css"; styleEl.textContent = allFontFaceRules; if (document.head) { document.head.appendChild(styleEl); } else { document.addEventListener("DOMContentLoaded", () => document.head.appendChild(styleEl), { once: true }); } } try { const dataToCache = { fontFaceRules: allFontFaceRules, processedComponentCss: allComponentCss }; sessionStorage.setItem(PAGY_WAND_FONT_CACHE_KEY, JSON.stringify(dataToCache)); } catch (e) { console.error("Pagy Wand: Failed to save font data to sessionStorage.", e); } return { processedComponentCss: allComponentCss }; })(); document.addEventListener("DOMContentLoaded", async () => { const PRESET = "pagy-wand-preset"; const OVERRIDE = "pagy-wand-override"; const POSITION = "pagy-wand-position"; const CONTROLS_CHK = "pagy-wand-controls-chk"; const HELP_CHK = "pagy-wand-help-chk"; const LIVE_CHK = "pagy-wand-live-chk"; const normalize = (str) => str.trim().replace(/\s+/g, " "); function getSessionItem(key) { return sessionStorage.getItem(key); } function setSessionItem(key, value) { if (typeof value === "string") { sessionStorage.setItem(key, value); } else { sessionStorage.setItem(key, JSON.stringify(value)); } } function removeSessionItem(key) { sessionStorage.removeItem(key); } function getSessionBoolean(key, defaultValue) { const value = sessionStorage.getItem(key); return value !== null ? JSON.parse(value) : defaultValue; } function getSessionObject(key) { const value = sessionStorage.getItem(key); return value ? JSON.parse(value) : null; } function hslaToRgba(hsla) { const h = parseFloat(hsla.hue) / 360; const s = parseFloat(hsla.saturation) / 100; const l = parseFloat(hsla.lightness) / 100; const a = parseFloat(hsla.alpha); let r, g, b; if (s === 0) { r = g = b = l; } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } const toHex = (x) => Math.round(x * 255).toString(16).padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`; } function rgbaToHsla(rgba) { const r = parseInt(rgba.slice(1, 3), 16) / 255; const g = parseInt(rgba.slice(3, 5), 16) / 255; const b = parseInt(rgba.slice(5, 7), 16) / 255; const a = parseInt(rgba.slice(7, 9), 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s, l = (max + min) / 2; if (max === min) { h = s = 0; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return { hue: (h * 360).toFixed(2), saturation: (s * 100).toFixed(2), lightness: (l * 100).toFixed(2), alpha: a.toFixed(3) }; } const host = document.createElement("div"); host.id = "pagy-wand-host"; document.body.appendChild(host); const s = parseFloat(document.getElementById("pagy-wand").getAttribute("data-scale")); const sliderHeight = s; const thumbDiameter = s * 1.2; const baseColor = "#484848"; const lightGray = "rgba(220,220,220,.6)"; const wandColor = "#81ffff"; const wandTint = "rgba(190,220,220,.6)"; const remSize = parseFloat(getComputedStyle(document.documentElement).fontSize); const style = document.getElementById("pagy-wand-default"); document.head.appendChild(style); const shadow = host.attachShadow({ mode: "closed" }); const { processedComponentCss } = await fontIsolation; shadow.innerHTML = `
PagyWand
content_copy

Install

Add this line in your HTML head

<%== Pagy.dev_tools %>

You can pass the optional wand_scale argument to change the size of the Wand.
Default: wand_scale: 1

Wand

Top Bar
Drag the Wand.
Top Bar Indicator Buttons
  • tune tuneToggle the Controls Section
  • help helpToggle the Help Section
  • visibility_off visibilityToggle the Live Preview
Presets
Pick a starting point to try and further customize.
Close Icon
There is no dynamic close button by design, so you won't forget to remove it in production.

Controls

Brightness
Toggle between Light and Dark theming calculation. Adjust the lightness after toggling.
Hue, Saturation, Lightness, Alpha
Generate any color. Notice that the automatic calculations work better within certain ranges/combinations.
Hex8
8-digit hex-color code: useful to quickly copy/paste and match a color from your app.
Spacing, Padding, Rounding, Borders
Control the layout and overall look.
Font Size, Font Weight, Line Height
Control the typography of the page links. Notice that the font-family is inherited from your app.
Interactions
The combination of Padding, Font Size, Line Height, controls the internal proportions of the page links.
CSS Override
The current set of .pagy rules.
  • content_copy Copy the CSS Override
  • check_circle Copied! Feedback
  • error Failed! Feedback

Customizing

• You can change Pagy's styling quite radically, by just setting a few CSS Custom Properties: the pagy.css or pagy-tailwind.css calculates all the other metrics.

• Pick a Presets as a starting point, customize it with the controls, and copy/paste the CSS Override in your Stylesheet.

• Add further customization to the .pagy CSS Override, or override the calculated properties for full control over the final style.

Important: Do not link the Pagy CSS file. Copy its customized content in your CSS, to avoid unwanted cosmetic changes that could happen on update.

`; const styleTagOverride = document.createElement("style"); styleTagOverride.id = "pagy-wand-override"; document.head.appendChild(styleTagOverride); const panel = shadow.getElementById("panel"); const topBar = shadow.getElementById("top-bar"); const presetMenu = shadow.getElementById("preset-menu"); const controlsChk = shadow.getElementById("controls-chk"); controlsChk.checked = getSessionBoolean(CONTROLS_CHK, false); const controlsIcon = shadow.getElementById("controls-icon"); const controlsDiv = shadow.getElementById("controls"); const helpChk = shadow.getElementById("help-chk"); helpChk.checked = getSessionBoolean(HELP_CHK, false); const helpIcon = shadow.getElementById("help-icon"); const helpDiv = shadow.getElementById("help"); const liveChk = shadow.getElementById("live-chk"); liveChk.checked = getSessionBoolean(LIVE_CHK, true); const liveIcon = shadow.getElementById("live-icon"); const liveStyle = document.getElementById("pagy-wand-default"); const copyIcon = shadow.getElementById("copy-icon"); const overrideArea = shadow.getElementById("override"); let controls = { brightness: { name: "--B", unit: "" }, hue: { name: "--H", unit: "" }, saturation: { name: "--S", unit: "" }, lightness: { name: "--L", unit: "" }, alpha: { name: "--A", unit: "" }, hex8: { name: "", unit: "" }, spacing: { name: "--spacing", unit: "rem" }, padding: { name: "--padding", unit: "rem" }, rounding: { name: "--rounding", unit: "rem" }, borderWidth: { name: "--border-width", unit: "rem" }, fontSize: { name: "--font-size", unit: "rem" }, fontWeight: { name: "--font-weight", unit: "" }, lineHeight: { name: "--line-height", unit: "" } }; function updateColorRamps() { const b = controls.brightness.input.value; let darker, lighter; if (b === "-1") { darker = "black"; lighter = "%23404040"; } else { darker = "%23B0B0B0"; lighter = "white"; } const gridUrl = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='5' height='5' fill='${darker}'/%3E%3Crect x='5' width='5' height='5' fill='${lighter}'/%3E%3Crect y='5' width='5' height='5' fill='${lighter}'/%3E%3Crect x='5' y='5' width='5' height='5' fill='${darker}'/%3E%3C/svg%3E")`; const h = controls.hue.input.value; const s = controls.saturation.input.value; const l = controls.lightness.input.value; const a = controls.alpha.input.value; const sliderWidth = controls.alpha.input.getBoundingClientRect().width; const thumbRadius = remSize * thumbDiameter / 2; const startSlider = thumbRadius + "px"; const endSlider = sliderWidth - thumbRadius + "px"; controls.hue.input.style.background = `linear-gradient(to right, hsl(0 100 50) ${startSlider}, hsl(60 100 50), hsl(120 100 50), hsl(180 100 50), hsl(240 100 50), hsl(300 100 50), hsl(360 100 50) ${endSlider})`; controls.saturation.input.style.background = `linear-gradient(to right, hsl(${h} 0 ${l}) ${startSlider}, hsl(${h} 100 ${l}) ${endSlider})`; controls.lightness.input.style.background = `linear-gradient(to right, hsl(${h} ${s} 0) ${startSlider}, hsl(${h} ${s} 50), hsl(${h} ${s} 100) ${endSlider})`; controls.alpha.input.style.backgroundImage = `linear-gradient(to right, hsla(${h} ${s} ${l} / 0) ${startSlider}, hsla(${h} ${s} ${l} / 1) ${endSlider}), ${gridUrl}`; controls.hex8.input.value = hslaToRgba({ hue: h, saturation: s, lightness: l, alpha: a }); const sample = shadow.getElementById("color-sample"); sample.style.backgroundImage = `linear-gradient(to right, hsla(${h} ${s} ${l} / ${a}), hsla(${h} ${s} ${l} / ${a})), ${gridUrl}`; } const finalize = () => { updateOverride(); presetMenu.value = ""; }; for (const [id, c] of Object.entries(controls)) { c.input = shadow.getElementById(id); if (id === "hex8") { c.input.addEventListener("change", () => { const hsla = rgbaToHsla(controls.hex8.input.value); controls.hue.input.value = hsla.hue; controls.saturation.input.value = hsla.saturation; controls.lightness.input.value = hsla.lightness; controls.alpha.input.value = hsla.alpha; finalize(); }); } else { c.input.addEventListener("input", () => { finalize(); }); } } const presets = { Default: ` .pagy { --B: 1; --H: 0; --S: 0; --L: 50; --A: 1; --spacing: 0.125rem; --padding: 0.75rem; --rounding: 1.75rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 600; --line-height: 1.75; } `, Dark: ` .pagy { --B: -1; --H: 0; --S: 0; --L: 60; --A: 1; --spacing: 0.125rem; --padding: 0.75rem; --rounding: 1.75rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 600; --line-height: 1.75; } `, MidnighExpress: ` .pagy { --B: -1; --H: 231; --S: 28; --L: 60; --A: 1; --spacing: 0.1875rem; --padding: 1rem; --rounding: 0.375rem; --border-width: 0rem; --font-size: 1rem; --font-weight: 450; --line-height: 1.25; } `, Pilloween: ` .pagy { --B: -1; --H: 20; --S: 80; --L: 50; --A: 1; --spacing: 0.375rem; --padding: 0.75rem; --rounding: 1.125rem; --border-width: 0.0625rem; --font-size: 0.875rem; --font-weight: 600; --line-height: 1.5; } `, Peppermint: ` .pagy { --B: 1; --H: 78; --S: 70; --L: 38; --A: 1; --spacing: 0.1875rem; --padding: 0.625rem; --rounding: 0.75rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 550; --line-height: 1.75; } `, CocoaBeans: ` .pagy { --B: 1; --H: 27; --S: 63; --L: 17; --A: 1; --spacing: 0.0625rem; --padding: 0.5rem; --rounding: 1.125rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 600; --line-height: 2.5; } `, PurpleStripe: ` .pagy { --B: 1; --H: 255; --S: 63; --L: 43; --A: 1; --spacing: 0rem; --padding: 0.875rem; --rounding: 0rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 300; --line-height: 1.5; } `, GhostInThought: ` .pagy { --B: 1; --H: 174; --S: 40; --L: 70; --A: 1; --spacing: 0.125rem; --padding: 0.75rem; --rounding: 1.125rem; --border-width: 0rem; --font-size: 0.875rem; --font-weight: 450; --line-height: 1.75; } `, VintageScent: ` .pagy { --B: 1; --H: 51; --S: 27; --L: 64; --A: 1; --spacing: 0.1875rem; --padding: 0.75rem; --rounding: 0.75rem; --border-width: 0.0625rem; --font-size: 0.875rem; --font-weight: 300; --line-height: 1.75; } ` }; for (const presetName in presets) { const option = document.createElement("option"); option.value = presetName; option.textContent = presetName; presetMenu.appendChild(option); } presetMenu.value = ""; presetMenu.addEventListener("change", (e) => { const name = e.target.value; setSessionItem(PRESET, name); applyCSS(presets[name]); }); function applyCSS(css) { css.match(/--[^:]+:\s*[^;]+/g)?.forEach((match) => { let [cssVarName, value] = match.split(":"); cssVarName = cssVarName.trim(); value = value.trim().replace(/[a-zA-Z%]+$/, ""); for (const c of Object.values(controls)) { if (c.name === cssVarName) { c.input.value = value; break; } } }); updateOverride(); } const initialOverride = getSessionItem(OVERRIDE); const presetName = getSessionItem(PRESET) ?? "Default"; const presetCSS = normalize(presets[presetName]); if (initialOverride && initialOverride !== presetCSS) { applyCSS(initialOverride); } else { presetMenu.value = presetName; applyCSS(presetCSS); } function updateOverride() { let override = `.pagy { `; Object.values(controls).forEach((c) => { if (c.name !== "") { override += ` ${c.name}: ${c.input.value}${c.unit}; `; } }); override += "}"; overrideArea.value = override; styleTagOverride.textContent = liveChk.checked ? override : ""; setSessionItem(OVERRIDE, normalize(override)); updateColorRamps(); } function getSessionPosition() { return getSessionObject(POSITION); } function setSessionPosition(left, top) { setSessionItem(POSITION, { left: parseFloat(String(left)), top: parseFloat(String(top)) }); } function keepTopBarInView() { const width = window.visualViewport ? window.visualViewport.width : document.documentElement.clientWidth; const height = window.visualViewport ? window.visualViewport.height : document.documentElement.clientHeight; const rect = topBar.getBoundingClientRect(); let newLeft = panel.offsetLeft; let newTop = panel.offsetTop; if (rect.left < 0) { newLeft = panel.offsetLeft - rect.left; } else if (rect.right > width) { newLeft = panel.offsetLeft - (rect.right - width); } if (rect.top < 0) { newTop = panel.offsetTop - rect.top; } else if (rect.bottom > height) { newTop = panel.offsetTop - (rect.bottom - height); } panel.style.left = `${newLeft}px`; panel.style.top = `${newTop}px`; setSessionPosition(newLeft, newTop); } let resizeTimeout; window.addEventListener("resize", () => { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = window.setTimeout(keepTopBarInView, 250); }); const position = getSessionPosition(); const wandPositioned = new CustomEvent("wand-positioned"); if (position && !isNaN(position.left) && !isNaN(position.top)) { panel.style.left = `${position.left}px`; panel.style.top = `${position.top}px`; document.dispatchEvent(wandPositioned); } else { panel.classList.add("initial"); requestAnimationFrame(() => { panel.classList.add("centered"); }); panel.addEventListener("transitionend", (e) => { if (e.propertyName === "transform") { panel.style.transition = "none"; const rect = panel.getBoundingClientRect(); panel.style.top = rect.top + "px"; panel.style.left = rect.left + "px"; setSessionPosition(rect.left, rect.top); panel.classList.remove("initial"); panel.classList.remove("centered"); document.dispatchEvent(wandPositioned); } }, { once: true }); } let offsetX = 0; let offsetY = 0; let dragging = false; topBar.addEventListener("mousedown", (e) => { if (e.target.closest("#preset-menu, label")) return; dragging = true; offsetX = e.clientX - panel.offsetLeft; offsetY = e.clientY - panel.offsetTop; topBar.style.cursor = "grab"; }); document.addEventListener("mousemove", (e) => { if (!dragging) return; e.preventDefault(); const newLeft = e.clientX - offsetX; const newTop = e.clientY - offsetY; panel.style.left = `${newLeft}px`; panel.style.top = `${newTop}px`; }); document.addEventListener("mouseup", (e) => { if (!dragging) return; dragging = false; topBar.style.cursor = "move"; const finalLeft = e.clientX - offsetX; const finalTop = e.clientY - offsetY; setSessionPosition(finalLeft, finalTop); }); function controlsSwitcher() { if (controlsChk.checked) { controlsIcon.classList.add("selected-icon"); controlsDiv.style.display = "grid"; helpChk.checked = false; helpSwitcher(); updateColorRamps(); } else { controlsIcon.classList.remove("selected-icon"); controlsDiv.style.display = "none"; } setSessionItem(CONTROLS_CHK, controlsChk.checked); } controlsSwitcher(); controlsChk.addEventListener("change", controlsSwitcher); function helpSwitcher() { if (helpChk.checked) { helpIcon.classList.add("selected-icon"); helpDiv.style.display = "block"; controlsChk.checked = false; controlsSwitcher(); } else { helpIcon.classList.remove("selected-icon"); helpDiv.style.display = "none"; } setSessionItem(HELP_CHK, helpChk.checked); } helpSwitcher(); helpChk.addEventListener("change", helpSwitcher); function liveSwitcher() { if (liveChk.checked) { liveIcon.classList.add("selected-icon"); liveIcon.textContent = "visibility"; liveStyle.disabled = false; } else { liveIcon.classList.remove("selected-icon"); liveIcon.textContent = "visibility_off"; liveStyle.disabled = true; } updateOverride(); setSessionItem(LIVE_CHK, liveChk.checked); } liveSwitcher(); liveChk.addEventListener("change", liveSwitcher); async function copyToClipboard() { const feedback = shadow.getElementById("copy-feedback"); const originalIcon = "content_copy"; feedback.classList.remove("visible"); try { await navigator.clipboard.writeText(overrideArea.value); copyIcon.textContent = "check_circle"; copyIcon.style.color = "limegreen"; feedback.textContent = "Copied!"; feedback.classList.add("visible", "success"); setTimeout(() => { copyIcon.textContent = originalIcon; copyIcon.style.color = lightGray; feedback.classList.remove("visible", "success"); }, 3000); } catch (err) { console.error('Failed to copy! (navigator.clipboard requires "localhost" or HTTPS) - ', err); copyIcon.textContent = "error"; copyIcon.style.color = "red"; feedback.textContent = "Failed!"; feedback.classList.add("visible", "failure"); setTimeout(() => { copyIcon.textContent = originalIcon; copyIcon.style.color = lightGray; feedback.classList.remove("visible", "failure"); }, 5000); } } copyIcon.addEventListener("click", copyToClipboard); const transitionStyleTag = document.createElement("style"); transitionStyleTag.id = "pagy-wand-transition"; transitionStyleTag.textContent = ` .pagy { transition: background-color 0.3s ease } .pagy a, .pagy label { transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, padding 0.3s ease, margin-left 0.3s ease, margin-right 0.3s ease, font-size 0.3s ease, font-weight 0.3s ease, line-height 0.3s ease, border-radius 0.3s ease } `; document.head.appendChild(transitionStyleTag); });