Skip to content

Instantly share code, notes, and snippets.

@stanwmusic
Created December 16, 2025 08:40
Show Gist options
  • Select an option

  • Save stanwmusic/b27eed98b842b0cf5b61933312f12825 to your computer and use it in GitHub Desktop.

Select an option

Save stanwmusic/b27eed98b842b0cf5b61933312f12825 to your computer and use it in GitHub Desktop.
Heartless by Josh Comeau
<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>
// 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;
}
.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