Skip to content

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).

<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.

The component draws the bitmap. You style how the <canvas> is displayed.

  1. JS (component). Centered cover crop, then draw into the canvas buffer. When pixel > 1, the buffer is smaller than the host. imageSmoothingEnabled = false keeps the downsample sharp inside that buffer.
  2. CSS (consumer). Stretch the canvas to the host (width / height: 100%). Set image-rendering: pixelated on the canvas for blocky upscale edges (typical look). Use auto for a softer mosaic, or crisp-edges where 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.

  • pixel (default 256): block size in CSS px. 0 = sharp, 256 = coarsest. Lower is sharper, higher is blockier. Not a 0-100 % pixelation slider.
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.).

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)
})
}

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)

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.

Mountain landscape

Scroll progress: 0.00, pixel: 256

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.

Snowy peaks

Intersection ratio: 0.00, pixel: 256

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.

Sequence photo 1Sequence photo 2Sequence photo 3

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.clientWidth
const height = $host.$canvas.clientHeight
const 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 source
const 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): 160, ...

Coastal cliffs

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,
)
})
Reveal photoAutumn trees

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
})
})

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)
})
Forest path

Hover or focus me

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.

Desert dunes

Map cursor position to pixel. The live demo shows both mappings side by side.

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()
})

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.

City skyline at night

pixel: 256

Horizontal

Left blocky, right sharp. Last value persists on mouseleave.

City skyline at night

pixel: 256

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>
Coastal cliffs
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()
})
})
Grid photo 1Grid photo 2Grid photo 3

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.

Lake reflection

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.

Northern lights

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.