In a world with , chapter 2.4.

THE PATH · SESSION 9 OF 66 · UNIT 2 · ACT I

Enchantments

Animations, Transforms, Images, and BEM

60 min read5 diagrams6 side quests4 live stages4,176 words16 code blocks

At a glance

PACE
Session 9 of 66 · suggested week 3. Continue when you are ready.
UNIT
Unit 2 · The First Skill I Learned in This World Was Making Things Pretty
ACT
Act I · Foundation
BEFORE
2.3 Formation Magic
AFTER
2.5 The Character Sheet Forge
ARTIFACT
enchantments.html, a small workshop of four components in BEM: buttons that lift, a card that lifts and recolors its title, a spinner built from keyframes, and a navigation underline that grows with scaleX; one image in a fixed frame with object-fit; prefers-reduced-motion respected; committed and pushed
READ
60 min · 5 diagrams · 16 code blocks

The hook#

The skeleton is named, the armor is on, and the formation holds when the window changes width. What you have is a page a person can read. What you do not have yet is a page that answers. A button that changes color between one frame and the next feels broken, even when the color is the right one. A card that lifts by a few pixels, on a curve, over a fifth of a second, feels like it was finished. The difference is not decoration. It is a signal: this can be touched, and the interface noticed.

The enchanter did not add a new bone. She put a breath in the ones already there. The clasp rose a finger's width when a hand came near, and settled when the hand left. A lantern at the gate turned, not because anyone wound it, but because turning was what it had been told to do, stop after stop, and then again. The people who preferred the room still got the room still. The enchantment knew when to be quiet.

Today is that breath. Transitions move one value to another when something changes, usually a hover. Transforms move, turn, and scale the paint without shoving the layout. Keyframe animations are a list of stops that can run on their own: a spinner, a fade-in, a pulse. Then two pieces of craft that keep the motion from becoming a mess. Images have to fit the frames the grid made, on purpose, with object-fit and aspect-ratio. BEM is a way of naming classes so a card's title cannot collide with a nav's title, and so every selector stays a single class. In Session 2.2 you learned that specificity fights are optional. BEM is how you decline them.

You will also learn when not to move. Some people get sick from motion. A query, prefers-reduced-motion, is how the page agrees to be still for them. It is not optional polish. It is part of the enchantment being polite.

The stages on this page cannot hold a <style> block, so a transition is started by setting the property from the script, and a spinner is run with the same frames the CSS would use. The blocks above each stage are the file you save. The stage is the proof that the browser will interpolate, and that a transform does not push its neighbor.

The idea#

The trip between two values#

A transition is a smooth change from the value a property has to the value it is about to have. Without one, hover snaps. With one, hover travels.

CSS
.btn {    background-color: #00a868;    transition: background-color 0.3s ease, transform 0.2s ease;} .btn:hover {    background-color: #007838;    transform: translateY(-2px);}

The hover rule is only the destination. The transition, written on the resting rule, is the journey. Put it on the resting rule so the trip runs in both directions: into the hover, and back out of it. A transition that lives only on :hover animates in and snaps back.

A resting green button and its lifted hover state above a timeline labeled delay, start, ease, and end. The transition declaration names transform, a duration of 0.2 seconds, the ease curve, and zero delay.
Fig 1 · A transition is the trip between two values. Put it on the resting rule so the trip works in both directions.

Four knobs, usually written as one shorthand: property, duration, timing function, delay.

transition-property is what is allowed to travel. Name the properties. all is a convenience that also animates things you did not mean to, including a width change from a stylesheet you forgot. List them.

transition-duration is how long. A tenth to a third of a second is the range that feels like a response. Two seconds is a demonstration, useful once, so you can see the curve, and then you put it back. Anything under about a tenth of a second reads as a snap. Anything over half a second on a hover reads as the interface lagging behind the hand.

transition-timing-function is the curve. ease starts and ends slowly, which is what a lift wants. linear moves at one speed, which is what a spinner wants and a hover usually does not. ease-in starts slow and arrives fast. ease-out starts fast and settles, which is the one to try when ease feels sluggish. ease-in-out does both. They are the same trip with a different pace. You are choosing a pace, not a different animation.

transition-delay waits before starting. Zero, unless you are staggering a row of things on purpose.

Not every property can travel. Color, background, opacity, transform, shadow, border color: yes, they are numbers or colors, and numbers can be halfway. display cannot be halfway between none and block. With the default transition behavior it switches immediately. Modern CSS can coordinate that switch with a fade using transition-behavior: allow-discrete, but it still does not invent a halfway display value. For this workshop, transition opacity and transform and leave display alone.

One more rule, the performance one. transform and opacity are cheap. The browser can hand them to the compositor and not redo layout. width, height, margin, top, and padding are expensive, because every frame asks the page to remeasure. A card that grows on hover should scale, not change its width. A card that rises should translateY, not a negative margin. The picture is the same. The cost is not.

The paint and the box#

A transform changes where an element is painted, how big it is painted, or how it is turned. It does not change the rectangle layout reserved for it. The neighbors stay. The gap stays. Hover a card with translateY(-8px) and the card below does not drop. That is the feature. If you wanted the card below to drop, you wanted margin, and you will also get the expensive reflow.

A dashed layout rectangle remains in place behind a shifted and rotated green card. Beside it, four labeled function cards show translateY, rotate, scale, and transform-origin: movement of paint versus the reserved layout box.
Fig 2 · The paint moves; the layout box stays. Choose the pivot with transform-origin.

CSS
.translate { transform: translateY(-8px); }.rotate    { transform: rotate(8deg); }.scale     { transform: scale(1.03); }.combo     { transform: translateY(-8px) rotate(-2deg); }

Several functions go in a single transform. Writing a second transform declaration replaces the first. It does not add. The order inside the one declaration is the order they apply: translate, then rotate, is not the same picture as rotate, then translate, because rotation happens around the origin and then the translation moves the result, or the other way around. For a hover lift you need one function and the order does not matter. For a stamp that spins out from a corner, it does.

transform-origin is the pivot. The default is the center. top left spins around the corner. It is not a movement of its own.

scale(1.03) on a card is about as much growth as still looks intentional. scale(1.2) is a lunge. translateY(-2px) is a button. translateY(-8px) is a card. Past that you are juggling, and the shadow has to grow with the lift or the card looks like it left its light behind. A shadow is box-shadow, and it transitions cleanly: from none or a tight shadow at rest, to a wider, softer one on hover.

:active is the pressed state. A button that rose on hover should come back down while the mouse is down, translateY(0), so the press has a bottom. Release, and the hover lifts it again, if the pointer is still there. Three states. Rest, hover, pressed. Each one is a rule. The transition between them is one declaration.

A list of stops#

A transition has two stops and needs a trigger. A keyframe animation has as many stops as you write, and it can start by itself, loop, and stay finished.

Three circles illustrate a pulse at zero, fifty, and one hundred percent: normal size, enlarged and translucent, then normal again. Two cards compare forwards, which retains the last keyframe, with none, which restores the underlying styles.
Fig 3 · Keyframes name the stops. Iteration controls repetition; fill mode controls which state remains.

CSS
@keyframes spin {    from { transform: rotate(0deg); }    to   { transform: rotate(360deg); }} @keyframes fade-in-up {    from { opacity: 0; transform: translateY(16px); }    to   { opacity: 1; transform: translateY(0); }} .spinner {    animation: spin 0.8s linear infinite;} .hero__title {    animation: fade-in-up 0.8s ease forwards;}

The shorthand is name, duration, timing function, delay, iteration count, direction, fill mode. You will not need all of them every time. You will need forwards whenever the animation's job is to arrive somewhere and stay. Without it, the element plays the last stop and then snaps back to the rule it had before the animation. A title that fades in and then vanishes was missing animation-fill-mode: forwards. The other value people meet is both, which also applies the first stop during the delay, so a delayed fade stays invisible while it waits instead of flashing the finished state and then disappearing. If the subtitle waits 0.3 seconds, set its opacity to 0 in the resting rule as well, and use forwards or both. Otherwise the delay shows the subtitle at full opacity, then the animation starts from invisible. That blink is the bug.

infinite loops. A count of 3 plays three times and, without forwards, ends at the start. alternate plays forward, then backward. A spinner wants linear and infinite. A pulse wants ease-in-out and infinite. An entrance wants ease and forwards and exactly one play.

animation-delay staggers. Three dots that bounce, with delays of -0.32s, -0.16s, and 0s, are the same animation started at different points in its cycle. A negative delay begins partway through, which is how they avoid stepping in unison. The negative number does not mean "wait negative time." It means "pretend this much of the animation already happened."

Names that say where they live#

BEM is not a tool. Nothing to install. It is three rules for class names.

A block is a component that means something alone: .quest-card, .navbar, .btn. An element is a part of that block, written with two underscores: .quest-card__title, .navbar__link. A modifier is a version, written with two hyphens: .quest-card--featured, .btn--danger, .quest-card__badge--legendary.

A card is labeled as a block, its title and button as elements, and a featured variant as a modifier. Class names use card, card__title, card__button, and card--featured, with each selector remaining one class.
Fig 4 · Two underscores mean part of; two hyphens mean a version of. Keep the base block class alongside its modifier.

The element name repeats the block. That looks long the first afternoon and saves you the first time two components both want a class called .title. .quest-card__title cannot collide with .navbar__title because they are different strings. You do not write .quest-card .title anymore. The descendant was only doing the job the longer name does in the open, and the descendant was two classes of specificity, (0, 2, 0), which is how the fights in Session 2.2 started. A BEM class is one class. The column stays at one. A modifier is a second class on the same element, still one column, and source order or the order you wrote the two rules decides the rest. You already know how to referee that.

The HTML carries both the block and the modifier: class="quest-card quest-card--featured". The modifier does not replace the block. It adds. The element does not include the modifier of the block in its own name unless the element itself varies: .quest-card__title is the title of every card, featured or not. .quest-card__badge--legendary is a badge that has a legendary version.

The frame and the picture#

The grid from last session made boxes. Photographs do not arrive in the shape of those boxes. A card image at height: 200px and width: 100% will stretch a portrait into a pancake unless you say how the pixels meet the frame.

The same square image, with a green circle and labeled top and bottom edges, appears in two 16:9 frames. Cover fills the frame and crops the edge labels; contain keeps both labels visible with empty bands at the sides.
Fig 5 · Cover fills and crops. Contain preserves every pixel. The frame and the picture are separate decisions.

object-fit: cover fills the frame and crops whatever does not fit. That is the one for cards and heroes, where an even row matters more than seeing the corner of the photo. object-fit: contain keeps every pixel and leaves bars. That is the one for a diagram, a logo, or a product shot that must not lose an edge. fill, the old default when you set both width and height, stretches. You almost never want fill. Say cover or contain out loud when you set the height, so the choice was a choice.

aspect-ratio: 16 / 9 holds the frame to a ratio so the height follows the width and a missing image does not collapse the card. Pair it with object-fit and width: 100%. display: block on the image removes the gap that inline images leave under themselves, a few pixels of line-height that read as a mysterious margin.

max-width: 100% and height: auto is still the rule for an image in a column of text, where there is no fixed frame. The fixed frame is for when the layout, not the file, decides the shape.

Two files for two screens is srcset and sizes, or a <picture> with a <source> per range. The browser picks. You still write one img with an alt, because the source that matches is what gets shown and the img is the fallback and the accessible name. Do not ship a card image with an empty alt unless it is decorative, and a quest banner is not decorative. This page cannot fetch a set of hero files, so the stage uses a drawing packed into the address. On your machine, use real files, and run them through something that compresses them before they go in the repository. A four-megabyte photograph in a 300-pixel card is a slow page with a small picture.

The demonstration#

The file is enchantments.html and enchantments.css, the same workshop the rest of the session finishes; what follows is its first third. The head is the boilerplate, the viewport, and the stylesheet link. Skip the Google Fonts line unless you want it. The body is a workshop: a row of buttons, a spinner, a card. BEM from the first class.

The buttons. Rest, hover, pressed. The transition lives on .btn. The destinations live on :hover and :active. Modifiers only change color.

CSS
.btn {    padding: 0.75rem 1.5rem;    border: 2px solid transparent;    border-radius: 6px;    font: 600 1rem/1 "Segoe UI", sans-serif;    cursor: pointer;    transition: background-color 0.3s ease, color 0.3s ease,                transform 0.2s ease, box-shadow 0.3s ease,                border-color 0.3s ease;} .btn:hover {    transform: translateY(-2px);    box-shadow: 0 4px 12px rgb(0 0 0 / 0.3);} .btn:active {    transform: translateY(0);    box-shadow: 0 1px 4px rgb(0 0 0 / 0.2);} .btn--primary {    background: #00a868;    color: #07110c;} .btn--primary:hover { background: #007838; } .btn--danger {    background: transparent;    color: #f03038;    border-color: #f03038;} .btn--danger:hover {    background: #f03038;    color: #07110c;}

Set the duration to 2s once, hover, and watch the curve. Then put it back. ease, linear, and ease-out on that slow one are the three paces. The stage runs the lift as a real CSS transition and asks the browser how many animations that transition became. Then it settles, so you can hover the card yourself. If your system has reduced motion on, the stage says so and the card stays put, which is the right answer. The listeners are only here. In the file, :hover is the whole mechanism.

HTMLlift.html
<article id="card">    <h3 id="title">Slay the dragon</h3>    <p>A short quest, so the lift is the point.</p>    <button type="button">Accept</button></article>
Stagelift.html, renderednot run yet

Slay the dragon

A short quest, so the lift is the point.

JSlift.js
const card = document.getElementById("card");card.style.padding = "16px";card.style.background = "#16161A";card.style.borderRadius = "8px";card.style.borderLeft = "4px solid #00A868";const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;card.style.transition = reduced ? "none" : "transform 400ms linear, box-shadow 400ms linear";card.style.transform = "translateY(0px)";card.addEventListener("mouseover", () => {  card.style.transform = reduced ? "none" : "translateY(-8px)";  card.style.boxShadow = "0 12px 24px rgb(0 168 104 / 0.35)";});card.addEventListener("mouseout", () => {  card.style.transform = "translateY(0px)";  card.style.boxShadow = "none";});if (!reduced) requestAnimationFrame(() => {  card.style.transform = "translateY(-12px)";  requestAnimationFrame(() => {    console.log("transition started, animations running:", card.getAnimations().length);  });});if (!reduced) setTimeout(() => { card.style.transform = "translateY(0px)"; }, 700);if (reduced) console.log("reduced motion is on, so the card does not lift");console.log("ok: lift");

The spinner. Two stops, linear, forever. The dots are one animation and three delays. This is also where BEM shows up in the names: .spinner-dots is the block, .spinner-dots__dot is the element. In the HTML the ring is one empty <div class="spinner"></div>, and the dots are <div class="spinner-dots"> holding three empty <span class="spinner-dots__dot"></span>.

CSS
@keyframes spin {    from { transform: rotate(0deg); }    to   { transform: rotate(360deg); }} .spinner {    width: 40px;    height: 40px;    border: 4px solid rgb(255 255 255 / 0.2);    border-top-color: #00a868;    border-radius: 50%;    animation: spin 0.8s linear infinite;} @keyframes bounce {    0%, 80%, 100% { transform: scale(0); }    40%           { transform: scale(1); }} .spinner-dots {    display: flex;    gap: 6px;} .spinner-dots__dot {    width: 12px;    height: 12px;    border-radius: 50%;    background: #00a868;    animation: bounce 1.4s ease-in-out infinite;} .spinner-dots__dot:nth-child(1) { animation-delay: -0.32s; }.spinner-dots__dot:nth-child(2) { animation-delay: -0.16s; }.spinner-dots__dot:nth-child(3) { animation-delay: 0s; } @media (prefers-reduced-motion: reduce) {    .spinner,    .spinner-dots__dot { animation: none; }}

bounce has three stops, not two, and the middle one is where each dot is at full size; the three :nth-child delays start each dot at a different point in the same cycle, which is the negative delay from the idea doing its job. The query is the polite part. A reader who has asked the operating system for less motion gets a still ring. The information was never in the spinning. The stage does the same check: if reduced motion is on, it does not leave the ring turning.

HTMLspin.html
<div id="ring"></div>
Stagespin.html, renderednot run yet
JSspin.js
const ring = document.getElementById("ring");ring.style.width = "40px";ring.style.height = "40px";ring.style.border = "4px solid rgb(255 255 255 / 0.2)";ring.style.borderTopColor = "#00A868";ring.style.borderRadius = "50%";const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;if (reduced) {  console.log("reduced motion is on, so the ring stays still");} else {  const anim = ring.animate(    [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],    { duration: 800, iterations: Infinity, easing: "linear" }  );  console.log("spinner state:", anim.playState);}console.log("ok: spin");

On your machine the same motion is the @keyframes rule, not a script. The stage uses the Web Animations API because this page is not allowed to insert a stylesheet. The frames are the ones in the CSS above.

The names. Take a card written with nested selectors and flatten it. Before: .card .title and .card.featured. After: .quest-card__title and .quest-card--featured. The stage is the after, and the script reads every class and says which kind it is. A name with __ is an element. A name with -- is a modifier. A name with neither, on this card, is the block. Anything that does not start with quest-card does not belong on it.

HTMLbem.html
<article id="card" class="quest-card quest-card--featured">    <p class="quest-card__badge quest-card__badge--legendary">Legendary</p>    <h3 class="quest-card__title">Slay the dragon</h3>    <p class="quest-card__description">A short quest with a long name, on purpose.</p>    <p class="quest-card__reward">500 gold</p></article>
Stagebem.html, renderednot run yet
JSbem.js
const card = document.getElementById("card");const names = new Set();card.querySelectorAll("*").forEach((el) => el.classList.forEach((name) => names.add(name)));card.classList.forEach((name) => names.add(name));names.forEach((name) => {  const kind = name.includes("__") ? (name.includes("--") ? "element modifier" : "element")    : name.includes("--") ? "block modifier" : "block";  console.log(kind.padEnd(18), name);});console.log("classes:", names.size, "· each one is a single class, specificity (0, 1, 0)");console.log("ok: bem");

The underline on a nav link is the one place a pseudo-element earns its keep, and it needs two things this unit has not used yet. The first is position. An element with position: relative stays exactly where the layout put it, and becomes the box that its positioned children measure from. A child with position: absolute is lifted out of the flow and placed by top, right, bottom and left against the nearest positioned ancestor, which is why the link gets relative: without it the bar would measure from the page. The second is content. A ::before or ::after is a box the stylesheet invents, and it does not exist until the rule gives it content, even an empty string; leave content: "" out and nothing is drawn, whatever else you set. The whole rule:

CSS
.navbar__link {    position: relative;    text-decoration: none;} .navbar__link::after {    content: "";    position: absolute;    left: 0;    bottom: -2px;    width: 100%;    height: 2px;    background: #00a868;    transform: scaleX(0);    transform-origin: left;    transition: transform 0.2s ease;} .navbar__link:hover::after {    transform: scaleX(1);}

The ::after is an empty bar, full width, two pixels tall, sitting just under the text, painted at scaleX(0), so it is there and invisible. The hover sets scaleX(1). The transition is on transform. The bar grows from the left because the origin is the left. No extra element in the HTML. No width being animated.

Where it breaks#

Your turn#

The quest#

Before Session 2.5, pushed:

  • enchantments.html meeting the eight requirements, including reduced motion and one covered image.
  • animated-card.html and animated-hero.html, the hover and the entrance.
  • The potion shop's classes rewritten as BEM, still the same page when you look at it.
  • about-me.html with two motions: a hover and an entrance is a good pair. Not five.

Read the GitHub Pages overview, ten minutes. The next session puts a page on the internet at a URL, and the reading is how that URL gets made. Read the accessibility overview, the opening only. You have been doing pieces of it since the alt text and the labels. The overview is the list those pieces belong to.

A short preview, because a codebase will hand it to you one day whether or not you go looking. SCSS is CSS with a compiler in front of it. Variables written $primary, selectors nested inside selectors, and mixins, which are reusable blocks that take arguments. Browsers never see SCSS. A compiler emits the CSS. Everything you wrote this unit is already valid SCSS, so a file can be renamed and adopted one component at a time. Nesting feels like the descendant selectors BEM just got you out of. Keep it to two or three levels, or the emitted CSS comes back more specific than you wanted. Native custom properties already cover the variables. Mixins are the part CSS still does not have. You do not need this for the workshop. You will recognize it when a codebase hands it to you.

Side quests#

Three pages in the library look further down roads you walked today. They are written for later units, so read the opening and the first picture, not the code.

Side quests from this session

  • DecoratorA modifier is a variation hung on a component that already exists: quest-card and quest-card--featured, not a second component. That is the decorator: same interface, another layer. Read the intent and the first picture. The wrapping example is the same move as a modifier class.
    Design Patterns
  • StateRest, hover, and pressed are three states of one button, and each state has its own declarations. The transition is only the trip between them. Read the intent. The class diagram is that idea pointed at a control instead of a quest card.
    Design Patterns
  • CompositeA block is a tree of named parts: the card contains a badge, a title, a description, a reward. The composite page is a tree where a part and a whole are addressed the same way. Read the intent and look at the first picture. Fig 4 on this session is that tree with the names written on it.
    Design Patterns
  • PicturesCompare cover and contain using the same photographs and frames. Rebuild a caption scrim, a deliberate crop, and a full-width image break from complete files.
    Atelier
  • MotionBuild a seamless moving strip and a scroll reveal with pause and reduced-motion behavior. The supplied scripts let you inspect the whole result now and revisit event handling after Session 3.5.
    Atelier
  • Gate, cursor, and soundRebuild an optional entrance dialog, cursor accent, and user-controlled sound. Complete scripts handle focus, closing, and playback; study the CSS states now and revisit the events after Session 3.5.
    Atelier

Before next session#

Turn reduced motion on, reload the workshop, and confirm the spinner is still. Turn it off and confirm the lift is back. That is the whole test, and it takes a minute.

Then open a site you think is polished and hover one button slowly enough to see the duration. Guess it. Check in the Styles panel. You will usually be within a tenth of a second, and that calibration is the skill. The rest is knowing which property is allowed to travel.

Next time: the character sheet. One landing page that uses the skeleton, the armor, the formation, and a little of today's motion, and then it goes on the internet with GitHub Pages. Have ready a picture of yourself or a mark you are willing to publish, and a sentence about what you are building. The enchantments stay small. The page is the point.

The bones did not change. They learned a breath, and they learned when to hold it.

See also