A Pen by Stan Williams on CodePen.
Created
December 16, 2025 08:40
-
-
Save stanwmusic/b27eed98b842b0cf5b61933312f12825 to your computer and use it in GitHub Desktop.
Heartless by Josh Comeau
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <style> | |
| @keyframes fadeToTransparent { | |
| to { | |
| opacity: 0; | |
| } | |
| } | |
| @keyframes disperse { | |
| to { | |
| transform: translate( | |
| calc(cos(var(--angle)) * var(--distance)), | |
| calc(sin(var(--angle)) * var(--distance)) | |
| ); | |
| } | |
| } | |
| .particle { | |
| animation: | |
| fadeToTransparent var(--fade-duration) forwards, | |
| disperse 500ms forwards var(--particle-curve); | |
| } | |
| </style> | |
| <button class="particleButton"> | |
| <svg | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| aria-hidden="true" | |
| > | |
| <path | |
| d="M3.68546 5.43796C8.61936 1.29159 11.8685 7.4309 12.0406 7.4309C12.2126 7.43091 15.4617 1.29159 20.3956 5.43796C26.8941 10.8991 13.5 21.8215 12.0406 21.8215C10.5811 21.8215 -2.81297 10.8991 3.68546 5.43796Z" | |
| stroke="white" | |
| stroke-width="2" | |
| stroke-linecap="round" | |
| /> | |
| </svg> | |
| <span class="visually-hidden">Like this post</span> | |
| </button> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Taken from the course of Josh | |
| // https://whimsy.joshwcomeau.com | |
| const btn = document.querySelector('.particleButton'); | |
| const FADE_DURATION = 1000; | |
| btn.addEventListener('click', () => { | |
| btn.classList.toggle('liked'); | |
| const isLiked = btn.classList.contains('liked'); | |
| if (!isLiked) { | |
| return; | |
| } | |
| const particles = []; | |
| range(5).forEach(() => { | |
| const particle = document.createElement('span'); | |
| particle.classList.add('particle'); | |
| const angle = random(0, 360); | |
| const distance = random(32, 40); | |
| // NOTE: Be sure to specify the angle as degrees, not pixels: | |
| particle.style.setProperty('--angle', angle + 'deg'); | |
| particle.style.setProperty('--distance', distance + 'px'); | |
| particle.style.setProperty('--fade-duration', FADE_DURATION + 'ms'); | |
| btn.appendChild(particle); | |
| particles.push(particle); | |
| }); | |
| window.setTimeout(() => { | |
| particles.forEach((particle) => { | |
| particle.remove(); | |
| }); | |
| }, FADE_DURATION + 200); | |
| }); | |
| /** | |
| * Produces a random number between the inclusive lower and upper bounds. | |
| * If only one argument is provided, a number between 0 and that number is returned. | |
| * If `floating` is true, or either bound is a float, a floating-point number is returned. | |
| * | |
| * @param {number} [lower=0] - The lower bound. | |
| * @param {number} [upper=1] - The upper bound. | |
| * @param {boolean} [floating] - Specify returning a floating-point number. | |
| * @returns {number} Returns the random number. | |
| */ | |
| function random(lower = 0, upper = 1, floating) { | |
| // If only one argument is passed, shift params | |
| if (upper === undefined) { | |
| upper = lower; | |
| lower = 0; | |
| } | |
| // Determine if result should be floating | |
| if ( | |
| floating === true || | |
| !Number.isInteger(lower) || | |
| !Number.isInteger(upper) | |
| ) { | |
| return Math.random() * (upper - lower) + lower; | |
| } | |
| // Inclusive integer | |
| return Math.floor(Math.random() * (upper - lower + 1)) + lower; | |
| } | |
| /** | |
| * Creates an array of numbers progressing from `start` up to, but not including, `end`. | |
| * | |
| * If `end` is not specified, it's set to `start` with `start` then set to 0. | |
| * A `step` of -1 is used if `start` is greater than `end`. | |
| * | |
| * @param {number} [start=0] - The start of the range. | |
| * @param {number} [end] - The end of the range (not included). | |
| * @param {number} [step=1] - The value to increment or decrement by. | |
| * @returns {number[]} Returns the range of numbers. | |
| */ | |
| function range(start = 0, end, step) { | |
| // Handle case where only one argument is provided | |
| if (end === undefined) { | |
| end = start; | |
| start = 0; | |
| } | |
| // Default step depending on direction | |
| if (step === undefined) { | |
| step = start < end ? 1 : -1; | |
| } | |
| const result = []; | |
| const ascending = step > 0; | |
| if (ascending) { | |
| for (let i = start; i < end; i += step) { | |
| result.push(i); | |
| } | |
| } else { | |
| for (let i = start; i > end; i += step) { | |
| result.push(i); | |
| } | |
| } | |
| return result; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .particle { | |
| position: absolute; | |
| inset: 0; | |
| margin: auto; | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: white; | |
| pointer-events: none; | |
| } | |
| html { | |
| --red: oklch(0.65 0.3 19.41); | |
| --particle-curve: cubic-bezier(0.2, 0.56, 0, 1); | |
| } | |
| body { | |
| display: grid; | |
| place-content: center; | |
| height: 100vh; | |
| background: hsl(210deg 15% 6%); | |
| } | |
| .particleButton { | |
| position: relative; | |
| padding: 16px; | |
| background: transparent; | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| &:hover { | |
| background: hsl(0deg 0% 100% / 0.1); | |
| path { | |
| stroke: var(--red); | |
| } | |
| } | |
| &.liked path { | |
| fill: var(--red); | |
| stroke: var(--red); | |
| } | |
| } | |
| .particleButton svg { | |
| position: relative; | |
| display: block; | |
| width: 3rem; | |
| height: 3rem; | |
| } | |
| .visually-hidden:not(:focus):not(:active) { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| overflow: hidden; | |
| clip: rect(0 0 0 0); | |
| clip-path: inset(50%); | |
| white-space: nowrap; | |
| } | |
| /* Define this utility function in our global styles: */ | |
| @function --convert-polar(--angle, --distance) { | |
| result: translate( | |
| calc(cos(var(--angle)) * var(--distance)), | |
| calc(sin(var(--angle)) * var(--distance)) | |
| ); | |
| } | |
| /* …and then use it in our keyframe animation: */ | |
| @keyframes disperse { | |
| to { | |
| transform: --convert-polar(var(--angle), var(--distance)); | |
| } | |
| } | |
| /* …or anywhere else in our CSS! */ | |
| /* .some-other-usecase { | |
| transform: --convert-polar(var(--angle), var(--distance)); | |
| } */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment