Photocopy code animation
<main>
<pre><div class="lid"></div><header><span class="lang">HTML</span><button aria-label="copy"><img src="media/copy.svg" alt="" eleventy:ignore /></button></header><code><span class="line"><span style="color:#F8F8F2">&lt;</span><span style="color:#FF79C6">h1</span><span style="color:#F8F8F2">&gt;</span></span>
<span class="line"><span style="color:#F8F8F2"> &lt;</span><span style="color:#FF79C6">span</span><span style="color:#50FA7B;font-style:italic"> class</span><span style="color:#FF79C6">=</span><span style="color:#E9F284">"</span><span style="color:#F1FA8C">word</span><span style="color:#E9F284">"</span><span style="color:#F8F8F2">&gt;Schitt&lt;</span><span style="color:#FF79C6">span</span><span style="color:#50FA7B;font-style:italic"> class</span><span style="color:#FF79C6">=</span><span style="color:#E9F284">"</span><span style="color:#F1FA8C">superscript</span><span style="color:#E9F284">"</span><span style="color:#F8F8F2">&gt;s&lt;/</span><span style="color:#FF79C6">span</span><span style="color:#F8F8F2">&gt; &lt;/</span><span style="color:#FF79C6">span</span><span style="color:#F8F8F2">&gt;</span></span>
<span class="line"><span style="color:#F8F8F2"> &lt;</span><span style="color:#FF79C6">span</span><span style="color:#50FA7B;font-style:italic"> class</span><span style="color:#FF79C6">=</span><span style="color:#E9F284">"</span><span style="color:#F1FA8C">word</span><span style="color:#E9F284">"</span><span style="color:#F8F8F2">&gt;Creek&lt;/</span><span style="color:#FF79C6">span</span><span style="color:#F8F8F2">&gt;</span></span>
<span class="line"><span style="color:#F8F8F2">&lt;/</span><span style="color:#FF79C6">h1</span><span style="color:#F8F8F2">&gt;</span></span></code></pre>
</main> @font-face {
font-family: "Reddit Mono";
src: url(./RedditMono-Regular.woff2);
font-display: swap;
}
body {
height: 100dvh;
margin: 0;
padding-inline: 0.5rem;
display: grid;
place-items: center;
background-color: hsl(199, 70%, 80%);
}
main {
/* this makes it a 3D animation */
perspective: 600px;
transform-style: preserve-3d;
}
pre:has(code) {
position: relative;
padding-inline: 0.75rem;
padding-block-start: 0.2rem;
padding-block-end: 1rem;
width: 100%;
/* restricting width to mirror width on a typical page */
max-width: 330px;
transform-style: preserve-3d;
/* add scrollbar if there is horizontal overflow */
overflow-y: auto;
font-family: "Reddit Mono", "JetBrains Mono", "Fira Code", monospace;
font-size: 1.2rem;
color: rgb(171, 184, 207);
background-color: #272822;
border-radius: 0.5rem;
header {
position: sticky;
left: 0;
display: grid;
grid-template-columns: min-content 1fr;
width: 100%;
min-height: 2.25rem;
padding: 0;
padding-block-start: 0.2rem;
span {
padding-block-start: 0.2rem;
align-self: center;
}
}
code {
display: block;
padding-block-start: 0.2rem;
}
}
button {
position: relative;
height: fit-content;
padding: 0.2rem 0;
justify-self: end;
align-self: center;
background-color: transparent;
border: none;
& img {
height: 1.2rem;
aspect-ratio: 1 / 1;
margin-block-end: 0;
pointer-events: none;
}
}
/* lid */
.lid {
content: "";
position: absolute;
left: 0;
top: 0;
border-radius: inherit;
height: 100%;
width: 100%;
background-color: white;
rotate: x 200deg;
opacity: 0;
transform-origin: center top;
z-index: 2;
}
@media (min-width: 600px) {
pre:has(code) {
max-width: 520px;
}
}
/* scrollbar */
pre::-webkit-scrollbar {
width: 2px;
height: 2px;
}
pre::-webkit-scrollbar-track {
background: none;
}
html {
scrollbar-width: auto;
scrollbar-color: hsl(39, 100%, 96%) hsl(39, 100%, 50%);
} let audioCtx;
let photocopierSound = {
path: "./media/photocopier.mp3",
buffer: null,
source: null,
};
init();
function init() {
// in production it would add to every copy button on the page
document
.querySelector("pre:has(code) button")
.addEventListener("click", async (event) => {
await copyClickHandler(event);
});
}
async function copyClickHandler(event) {
let pre = event.target.parentElement.parentElement;
copyCode(pre);
await runCopyAnimation(pre);
}
async function copyCode(block) {
let code = block.querySelector("code");
let text = code.innerText;
await navigator.clipboard.writeText(text);
}
async function loadAudio(sound) {
let buffer;
try {
const response = await fetch(sound.path);
buffer = await audioCtx.decodeAudioData(await response.arrayBuffer());
} catch (err) {
console.error(`Unable to fetch the audio file. Error: ${err.message}`);
}
return buffer;
}
async function playSound(sound) {
if (!audioCtx) {
audioCtx = new AudioContext();
}
if (sound.buffer === null) {
let buffer = await loadAudio(sound);
sound.buffer = buffer;
}
// Create a new single-use AudioBufferSourceNode
let source = audioCtx.createBufferSource();
source.buffer = sound.buffer;
source.connect(audioCtx.destination);
source.start();
sound.source = source;
return sound;
}
async function stopSound(sound) {
sound?.source.stop();
}
function disableButton(button) {
button.setAttribute("disabled", "");
}
function enableButton(button) {
button.removeAttribute("disabled");
}
/* For the lid to be visible, we need to allow the `pre` to overflow. Then, we
have an issue with the code content overflowing. To combat this, the `code`
element is set to the same width as the parent `pre` and its overflow is hidden.
*/
function enableOverflow(pre) {
pre.style.overflowY = "initial";
let code = pre.querySelector("code");
// I take away an arbitary amount from the width to compensate for the transformation
code.style.width = `${pre.offsetWidth - 12}px`;
code.style.overflow = "hidden";
}
/* This reverses the overflow changes we made in `enableOverflow()`. We want to allow
horizontal scrolling of the code block when code content that overflows.
*/
async function restoreOverflow(pre) {
pre.style.overflowY = "auto";
let code = pre.querySelector("code");
code.style.width = "auto";
code.style.overflow = "initial";
}
async function runCopyAnimation(pre) {
let lid = pre.querySelector(".lid");
let copyButton = pre.querySelector("button");
let numberOfFlashes = 5;
let flashDuration = 400;
disableButton(copyButton);
enableOverflow(pre);
photocopierSound = await playSound(photocopierSound);
// animations
flipCodeBlock();
showLid();
closeLid();
flashLights();
openLid();
unflipCodeBlock();
function flipCodeBlock() {
pre.animate(
{
rotate: "x 60deg",
},
{
duration: 200,
fill: "forwards",
}
);
}
function showLid() {
lid.animate(
{
opacity: 1,
},
{
duration: 1,
delay: 200,
fill: "forwards",
}
);
}
function closeLid() {
lid.animate(
{
rotate: "x 0deg",
},
{
duration: 400,
delay: 200,
fill: "forwards",
}
);
}
function flashLights() {
pre.animate(
{
boxShadow:
"0 0 2px 2px hsla(60, 100%, 50%, 0.9), 0 0 4px hsla(60, 100%, 50%, 0.8), 0 0 8px hsla(60, 100%, 50%, 0.6), 0 0 16px hsla(60, 100%, 50%, 0.3)",
},
{
duration: flashDuration,
fill: "forwards",
direction: "alternate-reverse",
iterations: numberOfFlashes,
easing: "ease-in-out",
delay: 400,
}
);
}
function openLid() {
lid.animate(
{
rotate: "x 200deg",
opacity: 0,
},
{
duration: 400,
delay: 200 + flashDuration * numberOfFlashes,
fill: "forwards",
}
);
}
function unflipCodeBlock() {
let animation = pre.animate(
{
rotate: "x 0deg",
},
{
duration: 200,
fill: "forwards",
delay: 200 + flashDuration * numberOfFlashes + 200,
}
);
animation.finished.then(() => {
stopSound(photocopierSound);
restoreOverflow(pre);
enableButton(copyButton);
});
}
} Description
A 3D animation for the copy code button of a code block. It mimicks a photocopier by flipping a lid and showing lights dancing beneath. It plays an accompanying sound effect - a beep for the button press followed by a mechanical scanning sound.
I used the Web Animation API to perform the animation in JavaScript. It was my first time using it for a significant animation. I found that I could guess my way through most of it by converting familiar CSS animation properties into keys used by the objects of animate(). However, it does not completely track as you would expect. For applying an easing, the key is not timing-function, it is easing. The values are hyphenated e.g. ease-in-out.
I had forgotten how clunky the Web Audio API is! I did not use the HTMLAudioElement object via Audio() because I heard, not sure if true or not, it can be laggy when playing sound clips on demand. I sourced the sound on https://freesound.org/.