Web Component that applies a shimmering pixel background on element hover.
A Pen by Ryan Mulligan on CodePen.
| <main> | |
| <div class="card"> | |
| <pixel-canvas></pixel-canvas> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentcolor" viewBox="0 0 256 256"> | |
| <path d="M216,42H40A14,14,0,0,0,26,56V200a14,14,0,0,0,14,14H216a14,14,0,0,0,14-14V56A14,14,0,0,0,216,42ZM40,54H216a2,2,0,0,1,2,2V98H38V56A2,2,0,0,1,40,54ZM38,200V110H98v92H40A2,2,0,0,1,38,200Zm178,2H110V110H218v90A2,2,0,0,1,216,202Z"></path> | |
| </svg> | |
| <button>Layout</buton> | |
| </div> | |
| <div class="card" style="--active-color: #e0f2fe"> | |
| <pixel-canvas data-gap="10" data-speed="25" data-colors="#e0f2fe, #7dd3fc, #0ea5e9"></pixel-canvas> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentcolor" viewBox="0 0 256 256"> | |
| <path d="M67.84,92.61,25.37,128l42.47,35.39a6,6,0,1,1-7.68,9.22l-48-40a6,6,0,0,1,0-9.22l48-40a6,6,0,0,1,7.68,9.22Zm176,30.78-48-40a6,6,0,1,0-7.68,9.22L230.63,128l-42.47,35.39a6,6,0,1,0,7.68,9.22l48-40a6,6,0,0,0,0-9.22Zm-81.79-89A6,6,0,0,0,154.36,38l-64,176A6,6,0,0,0,94,221.64a6.15,6.15,0,0,0,2,.36,6,6,0,0,0,5.64-3.95l64-176A6,6,0,0,0,162.05,34.36Z"></path> | |
| </svg> | |
| <button>Code</buton> | |
| </div> | |
| <div class="card" style="--active-color: #fef08a"> | |
| <pixel-canvas data-gap="3" data-speed="20" data-colors="#fef08a, #fde047, #eab308"></pixel-canvas> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentcolor" viewBox="0 0 256 256"> | |
| <path d="M180,146H158V110h22a34,34,0,1,0-34-34V98H110V76a34,34,0,1,0-34,34H98v36H76a34,34,0,1,0,34,34V158h36v22a34,34,0,1,0,34-34ZM158,76a22,22,0,1,1,22,22H158ZM54,76a22,22,0,0,1,44,0V98H76A22,22,0,0,1,54,76ZM98,180a22,22,0,1,1-22-22H98Zm12-70h36v36H110Zm70,92a22,22,0,0,1-22-22V158h22a22,22,0,0,1,0,44Z"></path> | |
| </svg> | |
| <button>Command</buton> | |
| </div> | |
| <div class="card" style="--active-color: #fecdd3"> | |
| <pixel-canvas data-gap="6" data-speed="80" data-colors="#fecdd3, #fda4af, #e11d48" data-no-focus></pixel-canvas> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentcolor" viewBox="0 0 256 256"> | |
| <path d="M222,67.34a33.81,33.81,0,0,0-10.64-24.25C198.12,30.56,176.68,31,163.54,44.18L142.82,65l-.63-.63a22,22,0,0,0-31.11,0l-9,9a14,14,0,0,0,0,19.81l3.47,3.47L53.14,149.1a37.81,37.81,0,0,0-9.84,36.73l-8.31,19a11.68,11.68,0,0,0,2.46,13A13.91,13.91,0,0,0,47.32,222,14.15,14.15,0,0,0,53,220.82L71,212.92a37.92,37.92,0,0,0,35.84-10.07l52.44-52.46,3.47,3.48a14,14,0,0,0,19.8,0l9-9a22.06,22.06,0,0,0,0-31.13l-.66-.65L212,91.85A33.76,33.76,0,0,0,222,67.34Zm-123.61,127a26,26,0,0,1-26,6.47,6,6,0,0,0-4.17.24l-20,8.75a2,2,0,0,1-2.09-.31l9.12-20.9a5.94,5.94,0,0,0,.19-4.31A25.91,25.91,0,0,1,56,166h70.78ZM138.78,154H65.24l48.83-48.84,36.76,36.78Zm64.77-70.59L178.17,108.9a6,6,0,0,0,0,8.47l4.88,4.89a10,10,0,0,1,0,14.15l-9,9a2,2,0,0,1-2.82,0l-60.69-60.7a2,2,0,0,1,0-2.83l9-9a10,10,0,0,1,14.14,0l4.89,4.89a6,6,0,0,0,4.24,1.75h0a6,6,0,0,0,4.25-1.77L172,52.66c8.57-8.58,22.51-9,31.07-.85a22,22,0,0,1,.44,31.57Z"></path> | |
| </svg> | |
| <button>Dropper</buton> | |
| </div> | |
| </main> |
Web Component that applies a shimmering pixel background on element hover.
A Pen by Ryan Mulligan on CodePen.
| class Pixel { | |
| constructor(canvas, context, x, y, color, speed, delay) { | |
| this.width = canvas.width; | |
| this.height = canvas.height; | |
| this.ctx = context; | |
| this.x = x; | |
| this.y = y; | |
| this.color = color; | |
| this.speed = this.getRandomValue(0.1, 0.9) * speed; | |
| this.size = 0; | |
| this.sizeStep = Math.random() * 0.4; | |
| this.minSize = 0.5; | |
| this.maxSizeInteger = 2; | |
| this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger); | |
| this.delay = delay; | |
| this.counter = 0; | |
| this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01; | |
| this.isIdle = false; | |
| this.isReverse = false; | |
| this.isShimmer = false; | |
| } | |
| getRandomValue(min, max) { | |
| return Math.random() * (max - min) + min; | |
| } | |
| draw() { | |
| const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5; | |
| this.ctx.fillStyle = this.color; | |
| this.ctx.fillRect( | |
| this.x + centerOffset, | |
| this.y + centerOffset, | |
| this.size, | |
| this.size | |
| ); | |
| } | |
| appear() { | |
| this.isIdle = false; | |
| if (this.counter <= this.delay) { | |
| this.counter += this.counterStep; | |
| return; | |
| } | |
| if (this.size >= this.maxSize) { | |
| this.isShimmer = true; | |
| } | |
| if (this.isShimmer) { | |
| this.shimmer(); | |
| } else { | |
| this.size += this.sizeStep; | |
| } | |
| this.draw(); | |
| } | |
| disappear() { | |
| this.isShimmer = false; | |
| this.counter = 0; | |
| if (this.size <= 0) { | |
| this.isIdle = true; | |
| return; | |
| } else { | |
| this.size -= 0.1; | |
| } | |
| this.draw(); | |
| } | |
| shimmer() { | |
| if (this.size >= this.maxSize) { | |
| this.isReverse = true; | |
| } else if (this.size <= this.minSize) { | |
| this.isReverse = false; | |
| } | |
| if (this.isReverse) { | |
| this.size -= this.speed; | |
| } else { | |
| this.size += this.speed; | |
| } | |
| } | |
| } | |
| class PixelCanvas extends HTMLElement { | |
| static register(tag = "pixel-canvas") { | |
| if ("customElements" in window) { | |
| customElements.define(tag, this); | |
| } | |
| } | |
| static css = ` | |
| :host { | |
| display: grid; | |
| inline-size: 100%; | |
| block-size: 100%; | |
| overflow: hidden; | |
| } | |
| `; | |
| get colors() { | |
| return this.dataset.colors?.split(",") || ["#f8fafc", "#f1f5f9", "#cbd5e1"]; | |
| } | |
| get gap() { | |
| const value = this.dataset.gap || 5; | |
| const min = 4; | |
| const max = 50; | |
| if (value <= min) { | |
| return min; | |
| } else if (value >= max) { | |
| return max; | |
| } else { | |
| return parseInt(value); | |
| } | |
| } | |
| get speed() { | |
| const value = this.dataset.speed || 35; | |
| const min = 0; | |
| const max = 100; | |
| const throttle = 0.001; | |
| if (value <= min || this.reducedMotion) { | |
| return min; | |
| } else if (value >= max) { | |
| return max * throttle; | |
| } else { | |
| return parseInt(value) * throttle; | |
| } | |
| } | |
| get noFocus() { | |
| return this.hasAttribute("data-no-focus"); | |
| } | |
| connectedCallback() { | |
| const canvas = document.createElement("canvas"); | |
| const sheet = new CSSStyleSheet(); | |
| this._parent = this.parentNode; | |
| this.shadowroot = this.attachShadow({ mode: "open" }); | |
| sheet.replaceSync(PixelCanvas.css); | |
| this.shadowroot.adoptedStyleSheets = [sheet]; | |
| this.shadowroot.append(canvas); | |
| this.canvas = this.shadowroot.querySelector("canvas"); | |
| this.ctx = this.canvas.getContext("2d"); | |
| this.timeInterval = 1000 / 60; | |
| this.timePrevious = performance.now(); | |
| this.reducedMotion = window.matchMedia( | |
| "(prefers-reduced-motion: reduce)" | |
| ).matches; | |
| this.init(); | |
| this.resizeObserver = new ResizeObserver(() => this.init()); | |
| this.resizeObserver.observe(this); | |
| this._parent.addEventListener("mouseenter", this); | |
| this._parent.addEventListener("mouseleave", this); | |
| if (!this.noFocus) { | |
| this._parent.addEventListener("focusin", this); | |
| this._parent.addEventListener("focusout", this); | |
| } | |
| } | |
| disconnectedCallback() { | |
| this.resizeObserver.disconnect(); | |
| this._parent.removeEventListener("mouseenter", this); | |
| this._parent.removeEventListener("mouseleave", this); | |
| if (!this.noFocus) { | |
| this._parent.removeEventListener("focusin", this); | |
| this._parent.removeEventListener("focusout", this); | |
| } | |
| delete this._parent; | |
| } | |
| handleEvent(event) { | |
| this[`on${event.type}`](event); | |
| } | |
| onmouseenter() { | |
| this.handleAnimation("appear"); | |
| } | |
| onmouseleave() { | |
| this.handleAnimation("disappear"); | |
| } | |
| onfocusin(e) { | |
| if (e.currentTarget.contains(e.relatedTarget)) return; | |
| this.handleAnimation("appear"); | |
| } | |
| onfocusout(e) { | |
| if (e.currentTarget.contains(e.relatedTarget)) return; | |
| this.handleAnimation("disappear"); | |
| } | |
| handleAnimation(name) { | |
| cancelAnimationFrame(this.animation); | |
| this.animation = this.animate(name); | |
| } | |
| init() { | |
| const rect = this.getBoundingClientRect(); | |
| const width = Math.floor(rect.width); | |
| const height = Math.floor(rect.height); | |
| this.pixels = []; | |
| this.canvas.width = width; | |
| this.canvas.height = height; | |
| this.canvas.style.width = `${width}px`; | |
| this.canvas.style.height = `${height}px`; | |
| this.createPixels(); | |
| } | |
| getDistanceToCanvasCenter(x, y) { | |
| const dx = x - this.canvas.width / 2; | |
| const dy = y - this.canvas.height / 2; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| return distance; | |
| } | |
| createPixels() { | |
| for (let x = 0; x < this.canvas.width; x += this.gap) { | |
| for (let y = 0; y < this.canvas.height; y += this.gap) { | |
| const color = this.colors[ | |
| Math.floor(Math.random() * this.colors.length) | |
| ]; | |
| const delay = this.reducedMotion | |
| ? 0 | |
| : this.getDistanceToCanvasCenter(x, y); | |
| this.pixels.push( | |
| new Pixel(this.canvas, this.ctx, x, y, color, this.speed, delay) | |
| ); | |
| } | |
| } | |
| } | |
| animate(fnName) { | |
| this.animation = requestAnimationFrame(() => this.animate(fnName)); | |
| const timeNow = performance.now(); | |
| const timePassed = timeNow - this.timePrevious; | |
| if (timePassed < this.timeInterval) return; | |
| this.timePrevious = timeNow - (timePassed % this.timeInterval); | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| for (let i = 0; i < this.pixels.length; i++) { | |
| this.pixels[i][fnName](); | |
| } | |
| if (this.pixels.every((pixel) => pixel.isIdle)) { | |
| cancelAnimationFrame(this.animation); | |
| } | |
| } | |
| } | |
| PixelCanvas.register(); |
| :root { | |
| --space: 1rem; | |
| --bg: #09090b; | |
| --fg: #e3e3e3; | |
| --surface-1: #101012; | |
| --surface-2: #27272a; | |
| --surface-3: #52525b; | |
| --ease-out: cubic-bezier(0.5, 1, 0.89, 1); | |
| --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| height, | |
| body { | |
| height: 100%; | |
| } | |
| body { | |
| display: grid; | |
| color: var(--fg); | |
| background: var(--bg); | |
| padding: var(--space); | |
| min-height: 100vh; | |
| } | |
| main { | |
| display: grid; | |
| grid-template-columns: repeat(var(--count, 1), 1fr); | |
| gap: var(--space); | |
| margin: auto; | |
| inline-size: min(var(--max, 15rem), 100%); | |
| @media (min-width: 25rem) { | |
| --count: 2; | |
| --max: 30rem; | |
| } | |
| @media (min-width: 45rem) { | |
| --count: 4; | |
| --max: 60rem; | |
| } | |
| } | |
| .card { | |
| position: relative; | |
| overflow: hidden; | |
| display: grid; | |
| grid-template-areas: "card"; | |
| place-items: center; | |
| aspect-ratio: 4/5; | |
| border: 1px solid var(--surface-2); | |
| isolation: isolate; | |
| transition: border-color 200ms var(--ease-out); | |
| user-select: none; | |
| &::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: radial-gradient( | |
| circle at bottom left, | |
| transparent 55%, | |
| var(--surface-1) | |
| ); | |
| pointer-events: none; | |
| box-shadow: var(--bg) -0.5cqi 0.5cqi 2.5cqi inset; | |
| transition: opacity 900ms var(--ease-out); | |
| } | |
| &::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| margin: auto; | |
| aspect-ratio: 1; | |
| background: radial-gradient(circle, var(--bg), transparent 65%); | |
| opacity: 0; | |
| transition: opacity 800ms var(--ease-out); | |
| } | |
| > * { | |
| grid-area: card; | |
| } | |
| svg { | |
| position: relative; | |
| z-index: 1; | |
| width: 30%; | |
| height: auto; | |
| color: var(--surface-3); | |
| transition: 300ms var(--ease-out); | |
| transition-property: color, scale; | |
| } | |
| button { | |
| opacity: 0; | |
| } | |
| &:focus-within { | |
| outline: 5px auto Highlight; | |
| outline: 5px auto -webkit-focus-ring-color; | |
| } | |
| &:where(:hover, :focus-within) { | |
| border-color: var(--active-color, var(--fg)); | |
| transition: border-color 800ms var(--ease-in-out); | |
| } | |
| &:where(:hover, :focus-within) svg { | |
| color: var(--active-color, var(--fg)); | |
| scale: 1.1; | |
| transition: 300ms var(--ease-in-out); | |
| } | |
| &:where(:hover, :focus-within)::before { | |
| opacity: 0; | |
| } | |
| &:where(:hover, :focus-within)::after { | |
| opacity: 1; | |
| } | |
| } |