Performance & UX
·16 min read

23 Awesome CSS Animation Examples with Demos + Live Code

Article updated on

One way people and corporations capture and hold their website visitors’ attention is through eye-catching animations. From subtle hover effects to dynamic interactions, animations can elevate the web surfing experience.

In this guide, we'll explore different types of CSS animations, how to implement them, and their real-world use cases.

When should you use CSS animations?

CSS animations can be good additions to your website, leading to an improved UX. However, adding them randomly or just because can negatively impact users or make an interface harder to use.

Before implementing an animation, run through this decision framework:

  • Does the animation improve the user experience? It should communicate a state change, provide or reinforce feedback, make transitions easier to follow, etc. If it doesn't do any of that, then is it actually needed?
  • Is the animation drawing attention to something important? Motion naturally catches people’s attention, making it useful for highlighting primary CTAs or important updates. If you animate several parts of the page once, that distracts the user and keeps them from focusing on what matters.
  • Will the animation help users understand what's changing? If you're only changing the color of a button after it's clicked, an animation may not be necessary. However, it can be worthwhile when you're expanding menus, transitioning between pages, or revealing additional content, as it helps users follow how the interface changes from one state to the next.

Entrance animations

1. Fade-in and slide-up on page load

A fade-in with a slight upward (or downward) movement is one of the most common entrance animations on the web. It's subtle enough to work in different use cases and is a solid choice for cards, modals, dialogs, and other content that appears as soon as a page loads.

/* Fade the card in and move it into place when the page loads. */
.card {
  opacity: 0;
  transform: translateY(16px);
  animation: fade-in-up 400ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

@keyframes fade-in-up {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Skip the animation and show the card immediately. */
  .card {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Here's a breakdown of the key implementation details:

  • It animates transform and opacity instead of top or margin-top, so the browser never has to recalculate the page layout while the animation runs.
  • Setting the animation's duration between 300 and 500ms gives users enough time to notice the transition without making the interface feel sluggish.
  • When prefers-reduced-motion is enabled, the animation is skipped entirely so the card appears immediately in its final position.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

This animation works especially well for Slices that appear near the top of a page, such as a Hero, Call to Action, or Feature section.

To integrate with Prismic, add a Select field called animation_style to your slice, e.g., a Hero slice, with options like fade-up, fade-in, and none. Your slice component can read that value and apply the corresponding CSS class, allowing editors to choose an entrance animation, or disable it entirely, without requiring any code changes.

// slices/Hero/index.tsx
export default function Hero({ slice }: HeroProps) {
  const animationClass = {
    "fade-up": "animate-fade-up",
    "fade-in": "animate-fade-in",
    none: "",
  }[slice.primary.animation_style ?? "none"];

  return (
    <section className={`hero ${animationClass}`}>
      <h1>{slice.primary.heading}</h1>
    </section>
  );
}

2. Staggered list animations

When several items appear at once, rather than instantly dropping everything onto the screen simultaneously, you can reveal them one after another to spice up the interface. This pattern works well for dropdown menus and a grid of cards.

.list__item {
  opacity: 0;
  transform: translateY(-15%);
  /* Keep the final state after each item finishes animating. */
  animation: fade-in 500ms ease-out forwards;

  @for $i from 1 through 5 {
    &:nth-of-type(#{$i}) {
      animation-delay: calc(sin(45deg) * #{$i}ms * 100);
    }
  }
}

@keyframes fade-in {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Show every item at once instead of staggering them. */
  .list__item {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Here’s a breakdown of how this works:

  • Each item gets its animation-delay set directly, in steps of about 70ms. That's enough of a gap for the eye to catch each item arriving on its own, without the whole list taking too long to finish appearing.
  • Each item starts hidden and slightly out of position. The forwards fill mode keeps the final state after the animation finishes so items don’t disappear again once they've arrived.
  • The example includes :focus-visible styles for both the trigger button and the list items, so keyboard users get the same focus indicator that mouse users get from hover states.
  • For users who prefer reduced motion, every item appears immediately instead of animating one after another.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

Store the list items in a repeatable Group field named items, with a Key Text field for each label.

When the slice renders, it loops over the Group field and outputs one list item for every entry. The animation delay is calculated from each item's position in the array, so the stagger adjusts automatically as editors add, remove, or reorder items. No additional CSS is needed when the content changes.

// slices/DropdownList/index.tsx
"use client";
export default function DropdownList({ slice }: DropdownListProps) {
  const [open, setOpen] = useState(false);
  return (
    <div className="list__container">
      <button
        className={open ? "open__btn active" : "open__btn"}
        onClick={() => setOpen(!open)}
      >
        {open ? "Hide items" : "Show items"}
      </button>
      {open && (
        <ul className="list">
          {slice.primary.items.map((item, index) => (
            <li
              key={index}
              className="list__item"
              style={{ animationDelay: `${index * 70}ms` }}
              tabIndex={0}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Loading state animations

1. Loading spinner

Loading spinners let users know that something is happening behind the scenes. They reassure people that the interface hasn't frozen and are a good choice for full-page loading states or actions with long processing times.

Spinners are also helpful for smaller interactions, such as submitting a form.

/* Create a spinning loader with a glowing dot that circles the edge. */
.ring {
  position: relative;
  width: 150px;
  height: 150px;
  border: 3px solid #3c3c3c;
  border-radius: 50%;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}

.ring::before {
  content: "";
  position: absolute;
  top: -3px;
  left: -3px;
  width: 100%;
  height: 100%;
  border: 3px solid transparent;
  border-top: 3px solid #fff000;
  border-right: 3px solid #fff000;
  border-radius: 50%;
  animation: rotate-arc 2s linear infinite;
}

.ring span {
  display: block;
  position: absolute;
  top: calc(50% - 2px);
  left: 50%;
  width: 50%;
  height: 4px;
  transform-origin: left;
  animation: rotate-comet 2s linear infinite;
}

.ring span::before {
  content: "";
  position: absolute;
  top: -6px;
  right: -8px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #fff000;
  box-shadow: 0 0 20px #fff000;
}

@keyframes rotate-arc {
  to {
    transform: rotate(360deg);
  }
}

@keyframes rotate-comet {
  from {
    transform: rotate(45deg);
  }
  to {
    transform: rotate(405deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Stop the animation and keep the loader visible. */
  .ring::before,
  .ring span {
    animation: none;
  }
}

Two elements are doing the work here, not one:

  • The loader is made up of two animated elements: the glowing arc and the comet dot. Since each rotates independently, the dot looks like it's “chasing the arc.”
  • The comet is set to rotate(45deg) at the start because it lines up with the visible end of the arc. This way, the two elements look like one continuous animation instead of separate shapes spinning around the ring.
  • Both animations use a linear timing function to maintain a constant rotation speed.
  • If a visitor has enabled reduced motion, the rotation stops, but the loader becomes a visible, static indicator that the action is still in progress.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

The loading state is controlled by your application's logic, while the button text would come from Prismic. A Key Text field such as button_label lets editors update the button copy without involving a developer.

When the form is submitted, the component temporarily replaces the button label with the spinner until the request completes. At that point, the component renders the original text back. This keeps the loading behavior in code while marketers can edit the content in Prismic.

// slices/NewsletterSignup/index.tsx
"use client";
export default function NewsletterSignup({ slice }: NewsletterSignupProps) {
  const [loading, setLoading] = useState(false);
  async function handleSubmit() {
    setLoading(true);
    await subscribe();
    setLoading(false);
  }
  return (
    <button onClick={handleSubmit} disabled={loading}>
      {loading ? (
        <span className="ring" role="status" aria-label="Loading">
          <span />
        </span>
      ) : (
        slice.primary.button_label
      )}
    </button>
  );
}

2. Skeleton loaders

These work best when the layout is predictable, like article cards, product cards, or user profiles. They can make loading feel faster by showing the page structure before the content arrives. This way users have an idea of what to expect.

/* Show a skeleton placeholder until the real content loads. */
.card {
  --card-height: 340px;
  --title-position: 24px 180px;
  --desc-line-position: 24px 242px;
  --avatar-position: 24px 24px;
  --footer-position: 0 calc(var(--card-height) - 40px);
  width: 280px;
  height: var(--card-height);
}

.card:empty::after {
  content: "";
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 6px;
  background-image:
    linear-gradient(90deg, rgba(211, 211, 211, 0) 0, rgba(211, 211, 211, 0.8) 50%, rgba(211, 211, 211, 0) 100%),
    linear-gradient(white 32px, transparent 0),
    linear-gradient(white 16px, transparent 0),
    radial-gradient(circle 16px at center, white 99%, transparent 0),
    linear-gradient(white 40px, transparent 0),
    linear-gradient(lightgrey var(--card-height), transparent 0);
  background-size: 200px 300px, 200px 32px, 230px 16px, 32px 32px, 100% 40px, 100% 100%;
  background-position: -150% 0, var(--title-position), var(--desc-line-position), var(--avatar-position), var(--footer-position), 0 0;
  background-repeat: no-repeat;
  animation: skeleton-shimmer 1.5s infinite;
}

/* Add another placeholder line by repeating the same pattern. */

@keyframes skeleton-shimmer {
  to { background-position: 350% 0, var(--title-position), var(--desc-line-position), var(--avatar-position), var(--footer-position), 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* Stop the shimmer and keep the placeholder visible. */
  .card:empty::after {
    animation: none;
  }
}

Here's a breakdown of how this skeleton works:

  • The :empty::after pseudo-element only exists while .card has no child elements. As soon as your application renders the real content inside the card, the skeleton automatically disappears on. No JavaScript needed.
  • The avatar, title, description, footer, and shimmer are all separate background layers stacked inside a single background-image. That lets one animation move across the entire placeholder instead of animating each shape individually.
  • Adding a second line of description text is just one more layer using the same pattern, a different width and a lower position, nothing structurally new
  • The custom properties at the top define the position of each placeholder. If you need to move the title or footer later, you only update the variable instead of multiple background declarations.
  • For anyone who has reduced motion enabled, the skeleton stays visible but the shimmer is removed. Users still know content is loading, just without the moving highlight.

Interactive CodePen loads when it gets near the viewport.

Hover and click feedback animations

1. Glassmorphic tile shine on card hover

This effect works well for things like feature cards and product highlights where you want a type of hover interaction that shifts from the norm. Since several animations run together, it's best reserved for a small number of cards instead of large grids with dozens of items.

/* Add a premium hover effect with a glow, animated tiles, and border lines. */
.card {
  position: relative;
  border-radius: 15px;
  transition: box-shadow 0.25s;
}

.card .shine {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.5s;
}

.card .shine::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 55%;
  width: 150%;
  padding-bottom: 150%;
  transform: translateX(-50%);
  filter: blur(35px);
  background-image: conic-gradient(from 205deg at 50% 50%, transparent 0deg, #10b981 25deg, rgba(52, 211, 153, 0.18) 295deg, transparent 360deg);
}

.card .background {
  position: absolute;
  inset: 0;
  overflow: hidden;
  mask-image: radial-gradient(circle at 60% 5%, black 0%, black 15%, transparent 60%);
}

.card .tile {
  position: absolute;
  background-color: rgba(16, 185, 129, 0.05);
  opacity: 0;
  animation-duration: 8s;
  animation-iteration-count: infinite;
}

/* Position the tiles and offset some of them so the flicker feels more natural. */
.card .tile-1 { top: 0; left: 0; height: 10%; width: 22.5%; }
.card .tile-4, .card .tile-6, .card .tile-10 { animation-delay: -2s; }

@keyframes tile-flicker {
  0%, 12.5%, 100% { opacity: 1; }
  25%, 82.5% { opacity: 0; }
}

.card .line::before,
.card .line::after {
  content: "";
  position: absolute;
  background-color: #2a2b2c;
  transition: transform 0.35s;
}

/* Reveal the border lines one after another instead of all at once. */
.card .line-1::before,
.card .line-1::after {
  transition-delay: 0.3s;
}

.card:hover .shine {
  opacity: 1;
}

.card:hover .tile {
  animation-name: tile-flicker;
}

.card:hover .line::before { transform: scaleX(1); }
.card:hover .line::after { transform: scaleY(1); }

@media (prefers-reduced-motion: reduce) {
  /* Keep the hover effect, but remove the animation. */
  .card .tile {
    animation: none;
  }

  .card,
  .card .shine,
  .card .tile,
  .card .line::before,
  .card .line::after {
    transition: none;
  }
}

Here's a breakdown of how the effect comes together:

  • The glow comes from a blurred conic gradient that's hidden until hover. Fading that layer in creates the glow without changing the card's background or border color.
  • The tile background isn't a repeating pattern. Each tile is positioned individually, while a mask-image fades the grid toward the edges so it blends naturally into the card.
  • Some of the tiles start with a negative animation-delay, so they begin at different points n the animaton cycle. This way, instead of every tile flickering in sync, they end up staggered, which is what makes the shimmer feel a little alive rather than mechanical
  • Simiilrly, each border line has its own transition-delay, so the outline is drawn in stages instead of appearing all at once.
  • With reduced motion enabled, the tiles stop flickering, while the rest of the hover state appears immediately without the staged transitions.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

Store your cards in a repeatable Group field named cards, with fields for the title and description. Editors can add, remove, or reorder the cards directly in the Page Builder, while the hover effect is applied automatically through your component's CSS.

// slices/CardGrid/index.tsx
export default function CardGrid({ slice }: CardGridProps) {
  return (
    <section className="grid">
      {slice.primary.cards.map((card, index) => (
        <div key={index} className="card">
          <h4>{card.title}</h4>
          <PrismicRichText field={card.description} />
          <div className="shine" />
        </div>
      ))}
    </section>
  );
}

2. Click-triggered button feedback with a shared toggle pattern

This pattern works well for interactive demos, onboarding highlights, or any action that must attract attention to drive engagement.

Once users click the button, the animation stops, making it a good fit for one-time prompts rather than everyday CTA buttons.

/* Make the button glow until it's been clicked. */
@keyframes glow {
  50% { box-shadow: 0 0 40px hsl(12, 100%, 60%); }
}

.btn[data-anim="glow"]:not(.toggled) {
  animation: glow 600ms ease-in-out infinite alternate;
}

@media (prefers-reduced-motion: reduce) {
  /* Keep the glow, but remove the pulsing animation. */
  .btn[data-anim="glow"]:not(.toggled) {
    animation: none;
    box-shadow: 0 0 40px hsl(12, 100%, 60%);
  }
}

Here's a breakdown of how it works:

  • The :not(.toggled) selector controls when the animation runs. By default, the button keeps glowing until a .toggled class is added, at which point the animation stops automatically. For a typical CTA, you'd probably want that logic flipped so motion responds to an action instead of running unprompted.
  • The data-anim attribute makes it easy to support multiple button animations without changing the component. Different values can trigger different effects while reusing the same markup.
  • The animation uses alternate, so each loop plays forward and backward instead of jumping back to its starting state every 600ms.
  • For people who prefer reduced motion, the glow stays visible without pulsing, so the button still stands, only without the continuous movement.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

Instead of hardcoding the effect, expose it as content. A Boolean field like draw_attention can turn the animation on or off, while a Select field named animation_style lets editors decide which effect to apply.

That way, highlighting an important action becomes a content decision, not a development task.

// slices/HighlightAction/index.tsx
export default function HighlightAction({ slice }: HighlightActionProps) {
  const animStyle = slice.primary.draw_attention ? slice.primary.animation_style ?? "glow" : undefined;
  return (
    <PrismicNextLink field={slice.primary.button_link} className="btn" data-anim={animStyle}>
      {slice.primary.button_label}
    </PrismicNextLink>
  );
}

Text animations

1. Typewriter effect in pure CSS

This works well for hero sections where you want to cycle through a handful of words or roles without relying on JavaScript. Developers typically use them for their portfolio sites.

Just make sure the background behind the text is a solid color that matches the page, otherwise, the typing effect can expose the edge of the animation.

.typewriter {
  --caret: currentcolor;
}

.typewriter::before {
  content: "";
  animation: typing 13.5s infinite;
}

.typewriter::after {
  content: "";
  border-right: 1px solid var(--caret);
  animation: blink 0.5s linear infinite;
}

/* Type the first word one letter at a time. */
@keyframes typing {
  0.0000%, 27.3488% { content: ""; }
  1.1395%, 26.2093% { content: "d"; }
  2.2791%, 25.0698% { content: "de"; }
  3.4186%, 23.9302% { content: "dev"; }
  4.5581%, 22.7907% { content: "deve"; }
  5.6977%, 21.6512% { content: "devel"; }
  6.8372%, 20.5116% { content: "develo"; }
  7.9767%, 19.3721% { content: "develop"; }
  9.1163%, 18.2326% { content: "develope"; }
  10.2558%, 17.0930% { content: "developer"; }
  /* Repeat the same pattern for the remaining words. */
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* Swap the typing effect for slower word changes. */
  .typewriter::after {
    animation: none;
  }

  @keyframes sequence-popup {
    0%, 100% { content: "developer"; }
    25% { content: "writer"; }
    50% { content: "reader"; }
    75% { content: "human"; }
  }

  .typewriter::before {
    content: "developer";
    animation: sequence-popup 12s linear infinite;
  }
}

Here's how it works:

  • The words aren't revealed from existing text. Instead, the content property on ::before changes over time, adding one character at each keyframe until the full word appears.
  • The animation is built around the exact words being typed. If you swap in different words, you'll usually need to update the keyframes so the typing stays in sync.
  • The blinking caret is a ::after pseudo element with its own animation, so it keeps blinking while the text is being typed.
  • Instead of freezing the animation completely, the reduced-motion version shows one complete word at a time, switching to the next every few seconds.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

This animation is one of the few in this guide that isn't editor-friendly. Since every keyframe is based on the exact number of characters in each word, the CSS can't simply pull values from a Key Text or Rich Text field.

One way around that is to store the words in a repeatable Group field and generate the keyframes when the Slice renders. A helper like buildTypewriterKeyframes below can calculate the timing for each word automatically, so editors can add, remove, or reorder words without having to ask developers to manually update the animation.

// slices/TypewriterHero/index.tsx
export default function TypewriterHero({ slice }: TypewriterHeroProps) {
  const words = slice.primary.words.map((item) => item.word);
  const keyframeCSS = buildTypewriterKeyframes(words);

  return (
    <section className="hero">
      <style>{keyframeCSS}</style>
      <h1>
        I'm a <span className="typewriter" />
      </h1>
    </section>
  );
}

// Custom helper, not a Prismic API. Turns a word list into the same kind of
// typing keyframes shown in the CSS above, just generated instead of hand-counted.
function buildTypewriterKeyframes(words: string[]): string {
  const slot = 100 / words.length; // % of the timeline each word gets
  const stops: string[] = [];

  words.forEach((word, i) => {
    const start = i * slot;

    // Type the word in, one character per stop
    for (let c = 0; c <= word.length; c++) {
      const percent = start + (c / word.length) * (slot * 0.4);
      stops.push(`${percent.toFixed(2)}% { content: "${word.slice(0, c)}"; }`);
    }

    // Jump straight to blank before the next word's slot begins
    stops.push(`${(start + slot - 0.01).toFixed(2)}% { content: ""; }`);
  });

  return `@keyframes typing {\n  ${stops.join("\n  ")}\n}`;
}

2. Glitch text effect

This effect works best for hero headings on gaming, cybersecurity, AI, or developer-focused sites where the visual style matches the brand. Avoid using it on longer blocks of text, since the constant movement will make the content harder to read.

.glitch {
  color: white;
  font-size: 100px;
  position: relative;
}

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  top: 0;
  color: white;
  background: black;
  overflow: hidden;
  clip: rect(0, 900px, 0, 0);
}

.glitch::before {
  left: -2px;
  text-shadow: 1px 0 blue;
  animation: noise-1 3s infinite linear alternate-reverse;
}

.glitch::after {
  left: 2px;
  text-shadow: -1px 0 red;
  animation: noise-2 2s infinite linear alternate-reverse;
}

/* Generate a different clip for each step in the animation. */
@keyframes noise-1 {
  $steps: 20;
  @for $i from 0 through $steps {
    #{percentage($i * (1 / $steps))} {
      clip: rect(random(100) + px, 9999px, random(100) + px, 0);
    }
  }
}

@keyframes noise-2 {
  $steps: 20;
  @for $i from 0 through $steps {
    #{percentage($i * (1 / $steps))} {
      clip: rect(random(100) + px, 9999px, random(100) + px, 0);
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Remove the glitch effect and show only the original text. */
  .glitch::before,
  .glitch::after {
    animation: none;
  }
}

Here's what's happening behind the scenes:

  • content: attr(data-text) copies the value from the element's data-text attribute into both pseudo-elements, so you only need to update the headline in one place.
  • The animation uses clip: rect() to reveal different horizontal slices of each text layer. clip is deprecated, but it still works in modern browsers. For new projects, consider using clip-path: inset() instead.
  • The jitter looks random because of Sass's random() function inside the @for loop, but that randomness only happens once, when the SCSS compiles down to plain CSS. s.That means each clip value is generated once, and the same sequence repeats every time the animation loops.
  • The two glitch layers animate at different speeds, so they gradually drift in and out of sync instead of moving together, making the effect feel less predictable.
  • Under reduced motion, the duplicate text layers disappear, leaving only the original headline without the glitch effect.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

Use the same Key Text field for both the heading and its data-text attribute. Editors only need to update the headline once, and the glitch effect automatically stays in sync.

// slices/GlitchHero/index.tsx
export default function GlitchHero({ slice }: GlitchHeroProps) {
  return (
    <h1 className="glitch" data-text={slice.primary.headline}>
      {slice.primary.headline}
    </h1>
  );
}

3. Wavy letter-by-letter text animation

This works well for loading screens and other decorative text where you want to add a bit of personality. It's less suitable for paragraphs or anything people need to read quickly.

.waviy span {
  position: relative;
  display: inline-block;
  animation: waviy 1s infinite;
  /* Uses each span's custom index to stagger the wave animation. */
  animation-delay: calc(0.1s * var(--i));
}

@keyframes waviy {
  0%, 40%, 100% {
    transform: translateY(0);
  }
  20% {
    transform: translateY(-20px);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Disables the wave animation so the text remains stationary. */
  .waviy span {
    animation: none;
  }
}

Here's a breakdown of how it works:

  • Each letter gets wrapped in its own span with a numbered custom property. CSS uses that number to calculate the animation delay, so every letter starts at a slightly different time without needing JavaScript to do the timing.
  • The letters spend most of the animation sitting in their normal position. They quickly bounce upward and back down, then stay still for the rest of the cycle before the animation repeats.
  • The animation uses transform: translateY() instead of changing top or bottom, so the browser can animate the letters without recalculating layout on every frame.
  • Under reduced motion, the letters stay in their normal position, so the heading remains completely static.

Prismic integration

Editors only manage the heading text in Prismic. When the component renders, the text in the component is split into individual letters, each letter is wrapped in a span, and each span gets its own index.

CSS then uses those indexes to automatically stagger the animation, so the wave works no matter how long or short the heading is.

// slices/WavyHeading/index.tsx
"use client";
export default function WavyHeading({ slice }: WavyHeadingProps) {
  const ref = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    const text = ref.current.textContent ?? "";
    ref.current.innerHTML = text
      .split("")
      .map((char, i) =>
        char === " " ? " " : `<span style="--i: ${i + 1}">${char}</span>`
      )
      .join("");
  }, []);

  return <h2 ref={ref} className="waviy">{slice.primary.heading}</h2>;
}

Scroll-triggered animations

1. Scroll progress bar in pure CSS with animation-timeline

Long-form content like blog posts is where this works best. It gives readers a subtle sense of how far they've read without distracting from the content.

/* This uses a scroll-linked animation to fill a progress bar as the page is scrolled from top to bottom. */
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  background: #6b6bff;
  transform-origin: 0 50%;
  transform: scaleX(0);
  animation: grow-progress linear;
  animation-timeline: scroll();
}

@keyframes grow-progress {
  to { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  /* Disables the scroll-linked animation so the progress bar remains hidden instead of growing as the page is scrolled. */
  .progress-bar {
    animation: none;
  }
}

Here's a breakdown of how it works:

  • animation-timeline: scroll() links the animation's progress to the page's scroll position. As the reader scrolls down, the progress bar grows.
  • animation-timeline comes after the animation shorthand because the shorthand resets it. If you reverse the order, the scroll-linked animation won't work.
  • The bar grows with transform: scaleX() instead of changing its width. That lets the browser update the animation without recalculating the page layout on every frame/
  • The animation shorthand doesn't include a duration because the scroll position controls the animation. In browsers that don't support scroll-driven animations, the bar stays at its initial scaleX(0) and remains invisible instead of breaking.
  • Under reduced motion, the progress bar is hidden completely since there's no animation driving it. If you still want to show reading progress, you'd need a JavaScript solution that updates the bar as the user scrolls.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

This is usually best handled in your shared layout instead of an individual Slice, since the progress bar tracks the entire page rather than a single piece of content.

A Boolean field named show_reading_progress on the blog post custom type lets editors decide which articles should display the progress bar without changing the layout’s code.

2. Full-screen section transitions with scroll-snap and view-timeline

This works well for full-screen storytelling experiences, landing pages, and portfolios where each section fills the viewport. Instead of scrolling from one section to the next, the content fades smoothly between sections, creating a cinematic transition.

/* This combines scroll snapping, scroll-driven animations, and view timelines to create a fullscreen crossfade transition between sections as you scroll. */
html {
  /* Forces the page to snap to each section rather than scrolling freely */
  scroll-snap-type: y mandatory;
}

.section {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  /* Gives this section its own named timeline for its children to reference */
  view-timeline: --section;
  height: 100dvh;
}

.content {
  /* Fixed positioning stacks every section's content on top of each other,
     so switching between them reads as a crossfade, not a scroll */
  position: fixed;
  inset: 0;
  overflow: hidden;
  animation: blink ease-in-out both;
  animation-timeline: --section;
}

@keyframes blink {
  0%, 100% {
    filter: blur(0.5rem) contrast(4);
    opacity: 0;
    visibility: hidden;
  }
  50% {
    filter: blur(0) contrast(1);
    opacity: 1;
    visibility: visible;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Restores the content to the normal document flow and removes the crossfade animation, so each section scrolls into view without overlapping or fading. */
  .content {
    position: static;
    animation: none;
    opacity: 1;
    filter: none;
    visibility: visible;
  }
}

Here are some of the key implementation details you should know:

  • The .content <div> uses position: fixed, so every section sits in the same place on the screen. As you scroll, the current section fades in while the previous one fades out, creating the illusion that one section is replacing another instead of physically scrolling past it.
  • Every .section element creates its own view-timeline, and the .content <div> inside it automatically follows that timeline. Even though every section uses the same timeline name, each one gets its own independent animation.
  • The blink keyframe animates three properties together: opacity, blur(), and contrast(). Instead of simply fading between sections, the content sharpens as it comes into view and softens again as it leaves.
  • The reduced-motion version does more than remove the animation. It also changes the .content <div> back to position: static, which returns every section to the normal document flow. Without that change, all of the fixed sections would remain stacked on top of one another, making the page unreadable.

One thing to keep in mind is browser support. At the time of writing, Firefox doesn't support CSS scroll-driven animations (animation-timeline and view-timeline) by default, so you'll need a fallback or polyfill for that browser.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

his pattern works well with repeatable Slices because each StorySection Slice becomes its own full-screen section, complete with its own scroll timeline. Editors simply add, remove, or reorder StorySection Slices in the Page Builder, and the transitions continue to work without any additional configuration.

// slices/StorySection/index.tsx
export default function StorySection({ slice }: StorySectionProps) {
  return (
    <section className="section">
      <div className="content">
        <h2>{slice.primary.heading}</h2>
        <PrismicRichText field={slice.primary.body} />
      </div>
    </section>
  );
}

Status and attention animations

1. Toast notification animations

Use a toast when you need to confirm that something just happened, like a form submission or a successful payment.

/* This automatically reveals a toast notification, keeps it visible briefly, then hides it again. It also lets users dismiss the toast instantly with a checkbox toggle. */
:root {
  --tr: all 0.5s ease 0s;
  --cs1: #005e38;
  --cs2: #03a65a;
  --cs3: #03a65a40;
}

.toast-item {
  overflow: hidden;
  max-height: 25rem;
  transition: var(--tr);
  animation: show-toast 4s ease 3s 1;
}

@keyframes show-toast {
  0%, 50%, 100% { max-height: 0; opacity: 0; }
  10%, 25% { max-height: 15rem; opacity: 1; }
}

.toast {
  background: linear-gradient(90deg, #1f2333, #22232b);
  color: #f5f5f5;
  padding: 1rem 2rem 1rem 6rem;
  border-radius: 0.25rem;
  transition: var(--tr);
}

.toast.success {
  --bg: var(--cs1);
  --clr: var(--cs2);
  --brd: var(--cs3);
}

/* A hidden checkbox, paired with a label on the icon, controls whether the toast is open */
input[type="checkbox"] {
  display: none;
}

#t-success:checked ~ .toast-panel .toast-item.success {
  max-height: 0;
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  /* Removes the automatic reveal animation and all transitions, so the toast appears and disappears immediately without sliding or fading. */
  .toast-item,
  .toast {
    animation: none;
    transition: none;
  }
}

Here's a breakdown of how it works:

  • A hidden checkbox controls whether each toast is open or closed. Clicking the “close” button changes that checkbox's state, and CSS handles the rest.
  • The show-toast animation briefly reveals the notification after the page loads before hiding it again. That gives the toast a chance to grab the user's attention without requiring any interaction.
  • Each toast type can have its own animation-delay, so multiple notifications don't all appear at the same time if they're rendered together.
  • Under reduced motion, the automatic reveal is removed completely, and opening or closing a toast happens instantly instead of sliding and fading.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

Toast notifications are usually triggered by application events, not by content in the Page Builder. What editors *can* control is the message itself.

Store the heading and body on a shared Settings custom type. That way, every form or feature that displays the same success message pulls from one place, so editors only have to update it once.

// components/SuccessToast.tsx
export default function SuccessToast({ settings }: { settings: SettingsDocument }) {
  return (
    <div className="toast success">
      <h3>{settings.data.success_toast_heading}</h3>
      <p>{settings.data.success_toast_body}</p>
    </div>
  );
}

2. Animated notification badge with pulse

Notification badges work best when you need to draw attention to something new, like unread messages, pending tasks, or items added to a cart.

/* This repeatedly pulses a notification badge while expanding a fading ring around it to draw attention to new activity. */
.badge-container {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.bell {
  font-size: 40px;
  line-height: 1;
}

.badge-num {
  position: absolute;
  top: -6px;
  right: -10px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  border: 0.15rem solid #fff;
  background: #ff0000;
  color: #fff;
  font-family: sans-serif;
  font-size: 0.75rem;
  font-weight: bold;
  line-height: 1;
  box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.2);
  animation: pulse 3s ease-in-out infinite;
}

.badge-num::after {
  content: "";
  position: absolute;
  inset: -0.1rem;
  border: 2px solid rgba(255, 0, 0, 0.5);
  border-radius: 50%;
  animation: sonar 3s ease-in-out infinite;
}

@keyframes pulse {
  0% { transform: scale(1); }
  10% { transform: scale(1.4); }
  25% { transform: scale(0.9); }
  40% { transform: scale(1.2); }
  50%, 100% { transform: scale(1); }
}

@keyframes sonar {
  0% { transform: scale(0.9); opacity: 1; }
  50% { transform: scale(2); opacity: 0; }
  50.01%, 100% { transform: scale(0.9); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* Stops the pulse and expanding ring animations, leaving the notification badge visible without the repeating attention-grabbing effect. */
  .badge-num,
  .badge-num::after {
    animation: none;
  }
}

This animation is fairly straightforward. The badge scales up and back down while a pseudo-element expands into a fading ring behind it. Both animations share the same timing, so they stay in sync and repeat every few seconds to draw attention.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

The unread count can come directly from a Number field named unread_count. If the value is greater than zero, the badge renders. If it's zero, the badge is omitted entirely.

This works well for counts that are fetched when the page loads or supplied by an integration. For live notification updates, you'd typically update the count with JavaScript instead of relying on a page refresh.

// slices/NotificationBell/index.tsx
export default function NotificationBell({ slice }: NotificationBellProps) {
  const count = slice.primary.unread_count;

  return (
    <div className="badge-container">
      <span className="bell">🔔</span>
      {count > 0 && <span className="badge-num">{count}</span>}
    </div>
  );
}

3. iMessage typing indicator

This is the classic chat bubble that shows else is actively typing. Three dots blink one after another while the message bubble gently expands and contracts.

/* This mimics a chat application's typing indicator by making the message bubble gently expand while its three dots blink one after another. */
.typing-indicator {
  $ti-color-bg: #e6e7ed;
  background-color: $ti-color-bg;
  width: auto;
  border-radius: 50px;
  padding: 20px;
  display: table;
  margin: 0 auto;
  position: relative;
  animation: 2s bulge infinite ease-out;

  &::before,
  &::after {
    content: "";
    position: absolute;
    bottom: -2px;
    left: -2px;
    height: 20px;
    width: 20px;
    border-radius: 50%;
    background-color: $ti-color-bg;
  }

  &::after {
    height: 10px;
    width: 10px;
    left: -10px;
    bottom: -10px;
  }

  span {
    height: 15px;
    width: 15px;
    float: left;
    margin: 0 1px;
    background-color: #9e9ea1;
    display: block;
    border-radius: 50%;
    opacity: 0.4;

    @for $i from 1 through 3 {
      &:nth-of-type(#{$i}) {
        animation: 1s blink infinite (#{$i} * 0.3333s);
      }
    }
  }
}

@keyframes blink {
  50% {
    opacity: 1;
  }
}

@keyframes bulge {
  50% {
    transform: scale(1.05);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Disables the blinking dots and bubble animation, leaving the typing indicator visible without any movement. */
  .typing-indicator,
  .typing-indicator span {
    animation: none;
  }
}

Here’s how it works:

  • The speech bubble's tail is made from two circles added with ::before and ::after, each slightly smaller than the last, which gives the bubble its familiar iMessage-style shape.
  • The Sass loop generates a separate :nth-of-type() rule for each dot, giving every one a slightly different animation delay so they blink one after another instead of all at once.
  • The dots spend most of their time at opacity: 0.4 and only briefly fade to full opacity, which makes the typing animation have a pulsing effect.
  • The bubble itself has a second animation that scales it up slightly and back down, adding a bit of movement underneath the blinking dots.
  • Under reduced motion, both animations stop. The bubble and dots stay visible, but nothing blinks or scales.

Interactive CodePen loads when it gets near the viewport.

Prismic integration

A typing indicator is driven by live chat activity rather than editorial content, so there's usually very little for Prismic to manage.

If you want editors to control whether the feature is available, add a Boolean field named enable_typing_indicator to a shared Settings custom type. The chat application still decides when to display the indicator, while Prismic controls where the feature is enabled on the website.

9 Common CSS animation mistakes and how to avoid them

CSS animations are easy to get started with, but there are some mistakes to watch out for while adding them to websites.

Here are some of the issues developers run into most often, and how to avoid them.

1. Leaving out animation-fill-mode: forwards

Entrance animations often end in a different visual state from where they begin. Without animation-fill-mode: forwards, the browser restores the element to its original styles as soon as the animation finishes.

For example, a modal that fades in from opacity: 0 may briefly appear before disappearing again because it returns to its starting state. Applying animation-fill-mode: forwards keeps the final keyframe in place after the animation completes.

2. Using keyframes when a transition is enough

While it may be tempting to use @keyframes every time you want to animate something, many UI interactions, such as buttons, links, cards, and form fields, only change between two states. In those cases, a CSS transition is simpler and easier to maintain. Save keyframe animations for more complex sequences, such as loading indicators or effects that involve multiple intermediate states.

3. Trying to animate non-animatable properties

Not every CSS property supports animation. E.g., display and background-image won't animate, regardless of how many keyframes you write.

If an animation doesn't seem to do anything, don't assume your syntax is wrong. Check resources like MDN and w3schools to see if the property you're animating is actually animatable before you start debugging.

4. Using transition: all everywhere

transition: all is convenient, especially when you're prototyping. However, it tells the browser to animate every animatable property on that element. For example, if you later add a box-shadow or change the element's width on hover, those changes will animate too, even if you never intended them to.

Instead, specify only the properties you want to animate, so your code is more predictable and easier to maintain.

transition: transform 200ms ease, opacity 200ms ease;

5. Animating layout properties instead of transform

Animating properties like width, height, margin, top, or left forces the browser to recalculate the page layout on every frame. This can cause poor performance on complex pages or lower-powered devices.

Whenever possible, animate properties like transform and opacity instead.

For example, slide a sidebar into view with transform: translateX() instead of changing its left position, or scale a button with transform: scale() instead of increasing its width. The animation looks the same, but the browser renders transform animations more efficiently.

6. Creating layout shifts with animations

Animations shouldn't cause the rest of the page to jump around unexpectedly. For example, an accordion that expands without enough space can push everything below it down.

Reserve space for animated content whenever possible, or choose animation techniques that minimize layout shifts. This creates a smoother experience and improves your site's Cumulative Layout Shift (CLS), one of Google's Core Web Vitals.

7. Using only hover animations

Hover animations work well on desktop devices, but touchscreens don't have a hover state. For example, if a tooltip explaining what a settings icon does only appears on hover, mobile and iPad users will never see it.

If an animation reveals important information or functionality, provide an equivalent experience for touch and keyboard users.

8. Making non-interactive elements look clickable

If a static element behaves like a button on hover, users will naturally expect it to be interactive. For example, giving a non-clickable card the same hover effect as your clickable cards can make users think it opens another page.

Only use interactive animations for elements that actually perform an action so your interface behaves the way users expect.

9. Ignoring prefers-reduced-motion

For some people, excessive motion can cause dizziness, nausea, or discomfort. Modern browsers support the prefers-reduced-motion media query so users can indicate that they'd like websites to reduce non-essential motion.

That doesn't mean removing every animation. Instead, simplify them where possible. For example, you can replace a large slide-in transition with a subtle fade or remove decorative motion while keeping the ones that communicate important feedback.

More CSS animation resources

We’ve explored various engaging animations and learned how they work. Check out other articles to see more CSS animation examples.

Article written by

Nefe Emadamerho-Atori

Nefe is an experienced front-end developer and technical writer who enjoys learning new things and sharing his knowledge with others.

More posts
Nefe Emadamerho-Atori

3 comments

waw ! amazing

Reply·1 year ago

biloldin

this is very good web

Reply·11 months ago

Sashi

Wow

Reply·10 months ago
Hit your website goals

Websites success stories from the Prismic Community

How Arcadia is Telling a Consistent Brand Story

Read Case Study

How Evri Cut their Time to Ship

Read Case Study

How Pallyy Grew Daily Visitors from 500 to 10,000

Read Case Study

From Powder to Pixels - Perfectly Planned Ski Vacations, Now Perfectly Digital

Read Case Study