Pixelate
<cinq-pixelate> wraps an <img> and a sibling <canvas>. It reads the
pixel attribute and redraws the canvas. Scroll, sliders, hover, and
animations stay in your app. The component only reflects pixel and
exposes sync().
pixel is the block size in CSS pixels (side length of each square), not
a pixelation percentage. 0 is sharp. Higher values mean larger blocks and a
blockier image (256 is the coarsest).
Markup
Section titled “Markup”<cinq-pixelate pixel="256"> <img src="/ui/demo/pixelate/scroll.jpg" crossorigin="anonymous" alt="Mountain landscape" width="960" height="540" /> <canvas role="img" aria-label="Mountain landscape"></canvas></cinq-pixelate>Layout is your responsibility: give the host a size (e.g. aspect-ratio),
stack <img> and <canvas> in the same box, keep the image at opacity: 0
(source + reduced-motion fallback), set object-fit: cover on the <img>.
Accessibility is also in your markup: meaningful alt on the <img>,
role="img" and aria-label on the visible <canvas>. Hide the source
<img> from assistive tech with aria-hidden="true" when the canvas is shown.
CORS: cross-origin images need CORS headers and crossorigin="anonymous"
on the <img>, or the canvas stays tainted. The demos below use same-origin
assets from /ui/demo/pixelate/.
cinq-pixelate { display: block; position: relative; aspect-ratio: 16 / 9; width: 100%; overflow: hidden;}
cinq-pixelate img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; pointer-events: none; z-index: 0;}
cinq-pixelate canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; z-index: 1; image-rendering: pixelated;}
@media (prefers-reduced-motion: reduce) { cinq-pixelate img { opacity: 1; }
cinq-pixelate canvas { display: none; }}When prefers-reduced-motion: reduce is set, hide the canvas and show the
<img> in CSS. The component does not read that media query.
Rendering
Section titled “Rendering”The component draws the bitmap. You style how the <canvas> is displayed.
- JS (component). Centered cover crop, then draw into the canvas buffer.
When
pixel>1, the buffer is smaller than the host.imageSmoothingEnabled = falsekeeps the downsample sharp inside that buffer. - CSS (consumer). Stretch the canvas to the host (
width/height: 100%). Setimage-rendering: pixelatedon the canvas for blocky upscale edges (typical look). Useautofor a softer mosaic, orcrisp-edgeswhere it behaves better in your target browsers.
At pixel 0 or 1, the buffer matches the display size (× devicePixelRatio
for a sharp result). image-rendering on the canvas has little effect there.
Attributes
Section titled “Attributes”pixel(default256): block size in CSS px.0= sharp,256= coarsest. Lower is sharper, higher is blockier. Not a 0-100 % pixelation slider.
Programmatic API
Section titled “Programmatic API”const $host = document.querySelector("cinq-pixelate")
$host.init()$host.setAttribute("pixel", "32")$host.sync() // redraw after layout changes$host.destroy()Public DOM refs: $img, $canvas. Constants: PIXEL_MIN (0, sharp) and
PIXEL_MAX (256, coarsest).
Call sync() after you change pixel from script if you need an immediate
redraw outside attributeChangedCallback (sliders, rAF loops, etc.).
Animation helper
Section titled “Animation helper”Several examples below animate pixel with the same helpers. animate
always returns a Promise that resolves when the transition finishes:
function ease(t) { return 1 - (1 - t) ** 3}
function animate($host, from, to, duration) { const start = performance.now()
return new Promise((resolve) => { function frame(now) { const t = Math.min(1, (now - start) / duration) $host.setAttribute("pixel", String(Math.round(from + (to - from) * ease(t)))) $host.sync() if (t < 1) { requestAnimationFrame(frame) return } resolve() } requestAnimationFrame(frame) })}Scroll reveal
Section titled “Scroll reveal”Map scroll or visibility to pixel. The three demos below exist because one
pattern is not enough on a real page:
- Scroll scrub: one host, one reveal window tied to page scroll
- Visibility ratio: one host, browser visibility metric, no scroll listener
- Staggered scroll scrub: many hosts, different reveal window each, one scroll pass (vertical gallery, editorial stack)
Scroll scrub
Section titled “Scroll scrub”One <cinq-pixelate>, one reveal window. A scroll listener coalesced with
requestAnimationFrame maps getBoundingClientRect() to pixel. Here the
reveal finishes when the host top crosses 40 % of the viewport:
Use this for a single hero (full-bleed photo, chapter opener) where scroll scrubs that block and nothing else on the page needs its own timing.
import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")const end = 0.4
function progress(element) { const { top, bottom } = element.getBoundingClientRect() const height = window.innerHeight
if (bottom <= 0 || top <= height * end) return 1 if (top >= height) return 0
return 1 - (top - height * end) / (height - height * end)}
function syncScrollScrub() { const value = progress($host) $host.setAttribute( "pixel", String(Math.round(PIXEL_MAX - value * (PIXEL_MAX - PIXEL_MIN))), ) $host.sync()}
let ticking = false
window.addEventListener( "scroll", () => { if (ticking) return ticking = true requestAnimationFrame(() => { syncScrollScrub() ticking = false }) }, { passive: true },)
syncScrollScrub()One hero: scroll scrubs a single reveal window for this block.
Scroll progress: 0.00, pixel: 256
Visibility ratio
Section titled “Visibility ratio”One host again, but a different driver. IntersectionObserver maps
intersectionRatio to pixel. No scroll listener. Sharp only once the host
is fully in view (ratio === 1):
Use this when visibility is enough: the element sharpens as it enters the viewport, without tuning scroll choreography.
import { PIXEL_MAX } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")
const observer = new IntersectionObserver( ([entry]) => { $host.setAttribute( "pixel", String(Math.round((1 - entry.intersectionRatio) * PIXEL_MAX)), ) $host.sync() }, { threshold: [0, 0.25, 0.5, 0.75, 1] },)
observer.observe($host)One host: sharpens from how much of it is visible, no scroll listener.
Intersection ratio: 0.00, pixel: 256
Staggered scroll scrub
Section titled “Staggered scroll scrub”Why this demo exists: scroll scrub works for one hero, but a page with
several <cinq-pixelate> in a column needs per-image timing. Without
stagger, every host would share the same reveal curve and sharpen together.
Several stacked hosts, one scroll listener. Stagger each host’s reveal
window with the loop index (start = index × 0.12, window length 0.45
viewport heights). While the first image is already sharp, the next can still
be blocky: an art-directed cascade on a single continuous scroll.
One listener, one sync per frame:
import { PIXEL_MAX } from "@agencecinq/pixelate"
const $hosts = document.querySelectorAll("[data-pixelate-sequence]")
function progress(element, index) { const { top, bottom } = element.getBoundingClientRect() const height = window.innerHeight const start = index * 0.12 const end = start + 0.45
if (bottom <= 0 || top <= height * (1 - end)) return 1 if (top >= height * (1 - start)) return 0
const range = height * (end - start) const traveled = height * (1 - start) - top
return Math.min(1, Math.max(0, traveled / range))}
function syncSequence() { $hosts.forEach(($host, index) => { $host.setAttribute( "pixel", String(Math.round((1 - progress($host, index)) * PIXEL_MAX)), ) $host.sync() })}
let ticking = false
window.addEventListener( "scroll", () => { if (ticking) return ticking = true requestAnimationFrame(() => { syncSequence() ticking = false }) }, { passive: true },)
syncSequence()Three stacked photos, one scroll listener. Each host gets a staggered reveal window from its index in the list. Scroll slowly: the first can be sharp while the next is still blocky.
Spinbutton
Section titled “Spinbutton”CINQ UI: cinq-spinbutton.
Drive pixel (block size) from a bounded numeric control. Decrease for
sharper, increase for blockier:
<cinq-spinbutton data-step="8"> <button type="button" name="decrease" tabindex="-1" aria-label="Decrease"> − </button> <input type="number" aria-label="Block size" aria-valuemin="0" aria-valuemax="256" aria-valuenow="160" aria-controls="pixelate-demo" value="160" /> <button type="button" name="increase" tabindex="-1" aria-label="Increase"> + </button></cinq-spinbutton>
<cinq-pixelate id="pixelate-demo" pixel="160"> <!-- img + canvas --></cinq-pixelate>import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("#pixelate-demo")const $spinbutton = document.querySelector("cinq-spinbutton")
$spinbutton.addEventListener(EVENTS.SPINBUTTON_CHANGE, (event) => { $host.setAttribute("pixel", String(event.detail.value)) $host.sync()})The live demo adds Download PNG: it re-renders the current pixel level at
display size (× devicePixelRatio) and exports that PNG. The image uses
crossorigin="anonymous" so export is allowed.
const width = $host.$canvas.clientWidthconst height = $host.$canvas.clientHeightconst pixel = Number($host.getAttribute("pixel") ?? 0)const dpr = window.devicePixelRatio || 1
// Same cover crop + downsample + upscale as cinq-pixelate, on an offscreen canvas...// use $host.$img as the drawImage sourceconst exportCanvas = document.createElement("canvas")exportCanvas.width = Math.round(width * dpr)exportCanvas.height = Math.round(height * dpr)
// ...then:const link = document.createElement("a")link.download = `photo-pixel-${pixel}.png`link.href = exportCanvas.toDataURL("image/png")link.click()pixel block size (px): ,
Switch
Section titled “Switch”CINQ UI: cinq-switch.
Binary sharp ↔ blocky. Uses the animation helper:
import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("cinq-pixelate")const $toggle = document.querySelector("cinq-switch")
$toggle.addEventListener(EVENTS.SWITCH_ACTIVATE, () => { animate( $host, Number($host.getAttribute("pixel") ?? PIXEL_MAX), PIXEL_MIN, 700, )})
$toggle.addEventListener(EVENTS.SWITCH_DEACTIVATE, () => { animate( $host, Number($host.getAttribute("pixel") ?? PIXEL_MIN), PIXEL_MAX, 700, )})Disclosure
Section titled “Disclosure”CINQ UI: cinq-disclosure-button.
Reveal on open, pixelate before close. Uses the animation helper. Swap the trigger label yourself. The component keeps its label fixed per the APG pattern:
<cinq-disclosure-button id="photo-disclosure"> <button type="button" aria-expanded="false" aria-controls="photo-panel"> View photo </button></cinq-disclosure-button>
<div id="photo-panel" hidden> <cinq-pixelate pixel="256"> <!-- img + canvas --> </cinq-pixelate></div>import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("cinq-pixelate")const $control = document.querySelector("#photo-disclosure")const $trigger = $control?.querySelector("button")const labels = { closed: "View photo", open: "Hide photo" }let closing = false
$trigger?.addEventListener(EVENTS.DISCLOSURE_BUTTON_OPEN, () => { $trigger.textContent = labels.open $host.setAttribute("pixel", String(PIXEL_MAX)) $host.sync() animate($host, PIXEL_MAX, PIXEL_MIN, 700)})
$trigger?.addEventListener(EVENTS.DISCLOSURE_BUTTON_CLOSE, (event) => { if (closing) return
event.preventDefault() closing = true
animate( $host, Number($host.getAttribute("pixel") ?? PIXEL_MIN), PIXEL_MAX, 700, ).then(() => { $control.close(false) $trigger.textContent = labels.closed closing = false })})Hover reveal
Section titled “Hover reveal”Animate pixel on enter/leave. Uses the animation helper:
import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")const $card = $host.closest("[data-pixelate-card]")
$card.addEventListener("mouseenter", () => { animate($host, Number($host.getAttribute("pixel") ?? PIXEL_MAX), PIXEL_MIN, 450)})$card.addEventListener("mouseleave", () => { animate($host, Number($host.getAttribute("pixel") ?? PIXEL_MAX), 48, 450)})Hover or focus me
Load reveal
Section titled “Load reveal”Use loading="lazy" on the <img> so the fetch waits until the host is near
the viewport. The reveal runs on load (uses the
animation helper):
<cinq-pixelate pixel="256"> <img src="photo.jpg" loading="lazy" crossorigin="anonymous" alt="Desert dunes" width="960" height="540" /> <canvas role="img" aria-label="Desert dunes"></canvas></cinq-pixelate>import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")const $img = $host.$img
function reveal() { animate($host, PIXEL_MAX, PIXEL_MIN, 900)}
if ($img.complete) reveal()else $img.addEventListener("load", reveal, { once: true })The image uses loading="lazy": the reveal runs when it enters the viewport and finishes loading.
Pointer position
Section titled “Pointer position”Map cursor position to pixel. The live demo shows both mappings side by side.
From center
Section titled “From center”Sharp at the center, blockier toward the edges. Reset on mouseleave:
import { PIXEL_MAX } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")
function pixelFromDistance($host, clientX, clientY) { const rect = $host.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const maxDistance = Math.hypot(rect.width / 2, rect.height / 2) const distance = Math.hypot(clientX - centerX, clientY - centerY) const ratio = Math.min(1, distance / maxDistance)
return ratio * PIXEL_MAX}
$host.addEventListener("mousemove", (event) => { const pixel = pixelFromDistance($host, event.clientX, event.clientY) $host.setAttribute("pixel", String(Math.round(pixel))) $host.sync()})
$host.addEventListener("mouseleave", () => { $host.setAttribute("pixel", String(PIXEL_MAX)) $host.sync()})Horizontal
Section titled “Horizontal”Left blocky, right sharp. The last value persists on mouseleave:
import { PIXEL_MAX } from "@agencecinq/pixelate"
const $host = document.querySelector("cinq-pixelate")
function pixelFromX($host, clientX) { const { left, width } = $host.getBoundingClientRect() const ratio = (clientX - left) / width
return (1 - ratio) * PIXEL_MAX}
$host.addEventListener("mousemove", (event) => { const pixel = pixelFromX($host, event.clientX) $host.setAttribute("pixel", String(Math.round(pixel))) $host.sync()})Map pointer position to pixel. Two mappings, side by side.
From center
Sharp in the middle, blocky at the edges. Resets on mouseleave.
pixel: 256
Horizontal
Left blocky, right sharp. Last value persists on mouseleave.
pixel: 256
Before / after comparison
Section titled “Before / after comparison”Uses <cinq-windowsplitter> in clip mode:
sharp <img> underneath, fixed blocky <cinq-pixelate> on top, clipped by the
separator.
<cinq-windowsplitter data-mode="clip" role="region" aria-label="Photo comparison"> <div class="comparison__after"> <img src="photo.jpg" crossorigin="anonymous" alt="" aria-hidden="true" /> </div> <div id="comparison-before" class="comparison__before"> <cinq-pixelate pixel="40"> <img src="photo.jpg" crossorigin="anonymous" alt="Coastal cliffs" /> <canvas role="img" aria-label="Pixelated coastal cliffs"></canvas> </cinq-pixelate> </div> <div role="separator" tabindex="0" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-controls="comparison-before" aria-label="Reveal original photo" ></div></cinq-windowsplitter>Multiple hosts, one controller
Section titled “Multiple hosts, one controller”const $hosts = document.querySelectorAll("[data-pixelate-grid]")const $slider = document.querySelector("#pixelate-grid-slider")
$slider.addEventListener("input", () => { $hosts.forEach(($host) => { $host.setAttribute("pixel", $slider.value) $host.sync() })})Layout resize
Section titled “Layout resize”The component observes host size via ResizeObserver and redraws on resize.
After abrupt layout changes, call sync() if the buffer looks stale (sync()
already batches the draw on the next frame):
const $shell = document.querySelector("#pixelate-resize-shell")const $host = document.querySelector("cinq-pixelate")
$shell.classList.toggle("w-[58%]")$shell.classList.toggle("w-full")$host.sync()Toggle width, then sync() if the buffer looks stale.
CINQ UI: cinq-modal, cinq-modal-button.
Open: listen for modal:open, then reveal. Uses the
animation helper:
import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("cinq-pixelate")
document.documentElement.addEventListener(EVENTS.MODAL_OPEN, (event) => { if (event.detail.modal !== "photo-modal") return $host.setAttribute("pixel", String(PIXEL_MAX)) $host.sync() animate($host, PIXEL_MAX, PIXEL_MIN, 700)})Close: listen for modal:before-close, preventDefault(), pixelate, then
event.detail.resolve(). Same $host and animate as above.
document.documentElement.addEventListener(EVENTS.MODAL_BEFORE_CLOSE, (event) => { if (event.detail.modal !== "photo-modal") return
event.preventDefault()
animate( $host, Number($host.getAttribute("pixel") ?? PIXEL_MIN), PIXEL_MAX, 700, ).then(() => { event.detail.resolve() })})Uses cinq-modal and cinq-modal-button.
Drawer
Section titled “Drawer”CINQ UI: cinq-drawer, cinq-drawer-button.
Open: listen for drawer:open, then reveal. Uses the
animation helper:
import { PIXEL_MAX, PIXEL_MIN } from "@agencecinq/pixelate"import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("cinq-pixelate")
document.documentElement.addEventListener(EVENTS.DRAWER_OPEN, (event) => { if (event.detail.drawer !== "photo-drawer") return $host.setAttribute("pixel", String(PIXEL_MAX)) $host.sync() animate($host, PIXEL_MAX, PIXEL_MIN, 700)})Close: listen for drawer:before-close, preventDefault(), depixelate, then
event.detail.resolve(). Same $host and animate as above.
document.documentElement.addEventListener(EVENTS.DRAWER_BEFORE_CLOSE, (event) => { if (event.detail.drawer !== "photo-drawer") return
event.preventDefault()
animate( $host, Number($host.getAttribute("pixel") ?? PIXEL_MIN), PIXEL_MAX, 700, ).then(() => { event.detail.resolve() })})Uses cinq-drawer and cinq-drawer-button.