Skip to content

Reveal Techniques

A UI principle for coding agents. Also covers reveal animation, wipe, unmask, line drawing, draw svg, stroke dash, and 7 more.

Show all 13 aliases

reveal animation, wipe, unmask, line drawing, draw svg, stroke dash, scroll reveal, offset-path, offset-distance, dot follows a line, marker along a path, image spills out of card on hover, reveal feels different when i resize

Revealing content is different from moving it. The element stays where it is and you animate what is visible, so nothing reflows and the effect stays cheap. Two tools cover almost every case: clip-path for surfaces, stroke dashes for lines.

Reveal A Surface With Clip-Path

clip-path hides part of an element without affecting layout. Animate the inset and the content appears to wipe into view.

Inset takes four sides in the order top, right, bottom, left. Start with one side at 100 percent to hide the element completely, then animate every side to zero.

<motion.img
  initial={{ clipPath: "inset(0 0 100% 0)" }}
  animate={{ clipPath: "inset(0 0 0 0)" }}
  transition={{ duration: 1, ease: [0.77, 0, 0.175, 1] }}
/>
  • Change which side starts at 100 percent to change the direction
  • Round the reveal with a radius, for example inset(0 0 0 0 round 12px)
  • Both states must use the same clip-path shape or the browser cannot interpolate

Prefer this over animating height or width. Those trigger layout on every frame; clip-path does not.

Pair The Wipe With A Small Transform

A wipe alone is clean but mechanical. Pair it with a small transform on the revealed content and give each property its own curve and duration inside the same transition list. The two finish at different moments, and that offset is most of what separates a hover reveal that feels considered from one that feels programmatic.

.card {
  overflow: hidden;
}

.card img {
  clip-path: inset(100% 0 0 0);
  scale: 1;
  transition:
    clip-path 500ms cubic-bezier(0.16, 1, 0.3, 1),
    scale 800ms cubic-bezier(0.33, 1, 0.68, 1);
}

.card:hover img,
.card:focus-visible img {
  clip-path: inset(0 0 0 0);
  scale: 1.06;
}
  • The container must hide its overflow. A scaling child grows in every direction, so without overflow: hidden on the frame it spills past the card and over whatever sits next to it. This is the "my image pokes out of the card on hover" bug
  • Keep the scale far smaller than instinct suggests. A tenth already reads as a zoom rather than a settle, so the usable band sits in the low single digits. Author it, look at it, then halve it
  • A few pixels of translate is the quieter alternative. A small vertical settle carries the same sense of the content arriving without the zoom's attention cost, and it suits text and dense content where a scale would blur or reflow the reading experience
  • Give each property a curve chosen for that property. The wipe and the settle are doing different jobs, so a single shared transition: all throws away the effect

A Wipe's Duration Depends On The Element's Width

A left-to-right wipe on a heading travels the heading's own width. The same duration therefore covers a short distance on a phone and a long one on a wide monitor, so a reveal tuned on a laptop snaps on mobile and crawls on a large display. The symptom is "it felt right until I resized the window".

  • Test every directional reveal at several viewport widths, including the widest one the layout allows, before calling it done
  • Where the width range is wide, derive the duration from the measured width so the speed stays constant instead of the duration
.heading {
  clip-path: inset(0 100% 0 0);
  transition: clip-path var(--reveal-duration, 600ms) cubic-bezier(0.16, 1, 0.3, 1);
}

.heading[data-revealed] {
  clip-path: inset(0 0 0 0);
}
const PIXELS_PER_MS = 1.6; // tune once against a mid-size viewport
const { width } = heading.getBoundingClientRect();
const duration = Math.min(Math.max(width / PIXELS_PER_MS, 300), 1200);
heading.style.setProperty("--reveal-duration", `${Math.round(duration)}ms`);
heading.dataset.revealed = "";
  • Clamp both ends. Without a floor the reveal is invisible on a narrow column; without a ceiling a full-bleed heading takes long enough to read as a stall
  • Recompute on resize only if the reveal can still run. Re-measuring after it has finished does nothing but cost layout work
  • A vertical wipe on a fixed-height element does not have this problem, because its travel distance does not depend on the viewport

Trigger A Reveal On Scroll

Fire the reveal when the element reaches the viewport, and fire it once. Re-running on every scroll past is distracting.

const ref = useRef(null);
const inView = useInView(ref, { once: true, margin: "0px 0px 100px 0px" });

Margin grows or shrinks the detection area. A positive bottom margin extends it below the fold, so the reveal starts just before the element arrives and is already underway when the user reaches it. A negative value does the opposite and delays the trigger until the element is further in, which is occasionally what you want for a heading that should not move while it is being read.

Draw A Line With Stroke Dashes

A dashed stroke takes a dash length and a gap length, and the pattern tiles along the whole path.

<line strokeDasharray="20 10" />

To draw a line rather than dash it, make the dash as long as the path itself, then push it out of view with an offset and animate the offset back to zero.

<motion.path
  d="M 10 50 L 90 50"
  fill="none"
  stroke="currentColor"
  strokeWidth={1.5}
  initial={{ pathLength: 0 }}
  animate={{ pathLength: 1 }}
  transition={{ duration: 1.5, ease: "easeInOut" }}
/>

A path needs fill="none" and a visible stroke or there is nothing to draw. Using currentColor lets the line inherit text colour and follow the theme.

pathLength handles the dash arithmetic for you and takes a value from 0 to 1, so the same code works whatever the path measures. Works on any stroked shape: lines, circles, rectangles, complex paths.

Ride A Marker Along The Path

offset-path gives an element a path to travel along. offset-distance says how far along it currently sits, as a percentage or a length. Animate offset-distance from 0% to 100% and the element follows the path.

This is how a dot stays pinned to the leading edge of a line that is being drawn. Give the marker the same path data the line uses, then run its offset-distance on the same duration and curve as the line's reveal, and the two stay locked together for free.

.marker {
  offset-path: path("M 10 50 C 60 10, 130 90, 190 30");
  offset-distance: 0%;
  offset-rotate: 0deg;
  animation: trace 1500ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
}

@keyframes trace {
  to {
    offset-distance: 100%;
  }
}
  • offset-rotate sets the element's orientation as it travels. auto turns it to face the direction of travel, which is what an arrowhead or a vehicle wants. A fixed angle keeps it upright, which is what a dot, a label or an avatar wants
  • offset-anchor picks which point of the element sits on the path. Set it explicitly when the marker should hang off the line, for example a callout whose corner touches the line rather than its centre
  • The path lives in the element's own coordinate space, so the marker and the line must share a coordinate system. Placing both inside the same sized container is the simplest way to guarantee that
  • Do not reimplement this with per-frame maths. Sampling the path with getPointAtLength inside a requestAnimationFrame loop does the same job on the main thread and drifts out of sync with the CSS animation driving the line. offset-path is the built-in that already solves it

Hold The Final Frame

A CSS keyframe animation snaps back to its starting state when it finishes unless animation-fill-mode says otherwise.

.reveal {
  animation: fade-up 600ms cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
}
  • forwards keeps the last frame, which is what a reveal almost always wants
  • Set animation-iteration-count to infinite only for ambient loops, never for a reveal
  • Reach for a keyframe animation when the motion has several stages; use a transition for a simple state change

A delay creates the mirror problem. Keyframe styles only apply while the animation runs, so a staggered reveal sits in its final visible state during its delay, then snaps hidden when the animation starts. backwards applies the first frame during the wait; both holds the first frame through the delay and the last frame after the end, which is what a delayed reveal wants.

.reveal:nth-child(2) {
  animation: fade-up 600ms cubic-bezier(0.165, 0.84, 0.44, 1) 100ms both;
}

A filled animation keeps overriding the properties it animated: base CSS and JavaScript changes to them will not take effect until the animation is removed or replaced. Do not fill an animation on a property you later need to change.

Stack A Reveal With An Ambient Loop

An element can run several comma-separated animations at once, each with its own duration, easing, delay and iteration count. A one-shot entrance composes with an infinite loop.

.badge {
  animation:
    settle-in 600ms ease-out both,
    float 3000ms ease-in-out 600ms infinite alternate;
}

@keyframes settle-in {
  from { opacity: 0; scale: 0.9; }
}

@keyframes float {
  to { translate: 0 -8px; }
}
  • Delay the loop by the entrance duration so the element settles before it starts drifting
  • Keep each animation on its own property: here the entrance owns opacity and scale, the float owns translate. When two animations write the same property, the later one in the list wins and the earlier one is silently ignored

Animate Transform Channels Independently

The standalone translate, rotate and scale properties animate each transform channel without rewriting the full transform list. That is what makes the stacking above work for movement: a float loop can own translate while the entrance owns scale, where two keyframes both setting transform would overwrite each other and only one would run.

@keyframes fade-up {
  from { opacity: 0; translate: 0 16px; }
}

@keyframes settle {
  from { scale: 0.9; }
}
  • The channels compose in a fixed order, as if you had written transform: translate() rotate() scale(): the element scales and rotates in place, then moves. Declaring them in a different order changes nothing
  • That bites when the translation belongs inside the rotation, for example an orbit. For those cases, and for skews, 3D transforms or single-axis functions, use transform and control the order yourself

Never Hide Content From Reduced Motion

A reveal starts from a hidden state, so the hidden state is the one thing that must never be the default. Declare the finished state in the ordinary rule and put the hidden start, the animation and the transition inside @media (prefers-reduced-motion: no-preference). Written that way the preference costs the user the movement and nothing else, and a browser that cannot evaluate the query lands in the same safe place, because the feature has two values and an unevaluable query matches neither. accessibility.md has the argument in full.

.reveal {
  opacity: 1;
  scale: 1;
}

.marker {
  offset-path: path("M 10 50 C 60 10, 130 90, 190 30");
  offset-rotate: 0deg;
  offset-distance: 100%;
}

@media (prefers-reduced-motion: no-preference) {
  .reveal {
    animation: fade-up 600ms cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
  }

  .marker {
    offset-distance: 0%;
    animation: trace 1500ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
  }
}

Read the ungated rules on their own and ask what the user gets: content at full opacity and full size, and a marker parked at the end of its path, exactly where the finished animation would have left it. Nothing is hidden and nothing is stranded.

Every starting state a reveal introduces belongs inside the gate on the same principle. The clip that hid the surface, the scale or translate paired with it, and the offset-distance: 0% that parks a marker at the start of its path are all animation inputs, not resting values, so leaving any of them ungated turns the preference into missing content and a marker in the wrong place.

The same applies in JavaScript: when the query does not report no-preference, render the element in its revealed state rather than skipping the code that would have revealed it. Test the positive query, matchMedia("(prefers-reduced-motion: no-preference)").matches, so a browser that cannot evaluate it still shows the content.

Checklist

  • Reveals animate clip-path, not height or width
  • Start and end states use the same clip-path shape
  • A wipe paired with a transform gives each property its own curve and duration, never transition: all
  • Any container holding a scaling child sets overflow: hidden
  • Paired scale stays in the low single digits, or is swapped for a small vertical settle
  • Directional reveals are tested at several viewport widths, and derive their duration from measured width where the range is wide
  • A marker that follows a line uses offset-path and offset-distance, not per-frame path sampling
  • Scroll-triggered reveals run once
  • Drawn paths set fill="none" and a visible stroke
  • Line drawing uses pathLength rather than hand-computed dash values
  • Keyframe reveals set forwards so the end state holds
  • Delayed staggered reveals use backwards or both so the hidden first frame holds during the wait
  • Under prefers-reduced-motion the content is shown in its revealed state, never left hidden

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "reveal-techniques" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems