Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

CSS Animations in WordPress Without a Plugin or a Performance Hit

Sean

Platform Writer

Aug 10, 2026
8 min read

You can add animations to WordPress with plain CSS in a child theme’s stylesheet — no plugin, no JavaScript library, no page builder. Scroll-triggered animations now work in CSS alone via scroll-driven animations, with an Intersection Observer fallback of about ten lines. The important part is animating only transform and opacity, because anything else costs you layout work on every frame.

CSS Animations in WordPress Without a Plugin or a Performance Hit

Animation plugins add a stylesheet and a script to every page on your site so that one heading can fade in. The CSS you need is short enough to write yourself, and writing it yourself is how you avoid the two things that make animated sites feel worse rather than better: janky frames and layout shift.

Table of contents

Where to put the CSS

Not in the parent theme’s style.css, which an update overwrites. Three correct places, in order of preference:

  1. A child theme’s stylesheet — the standard answer for anything substantial.
  2. Appearance → Customize → Additional CSS — survives theme updates, good for small additions, though it loads inline on every page.
  3. A small plugin of your own enqueueing a stylesheet — the right choice if the animations should survive a theme change.
// In a child theme's functions.php
add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_style(
        'rb-animations',
        get_stylesheet_directory_uri() . '/css/animations.css',
        array(),
        filemtime( get_stylesheet_directory() . '/css/animations.css' )
    );
} );

Use filemtime as the version string. It changes whenever you edit the file, which busts browser and CDN caches automatically. Hardcoding '1.0' means your edits do not reach visitors until they clear their cache, and that wastes an astonishing amount of debugging time.

Animate transform and opacity, nothing else

This is the rule that determines whether your animation runs at 60fps or stutters.

Browsers render in stages: style, layout, paint, composite. Changing width, height, top, left, margin, or padding forces layout recalculation on every frame, and layout is expensive. transform and opacity can be handled by the compositor, skipping layout and paint entirely.

/* Slow: triggers layout on every frame */
.card:hover {
  width: 320px;
  margin-top: -10px;
}

/* Fast: compositor only */
.card {
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.card:hover {
  transform: translateY(-10px) scale(1.02);
}
  • Movetransform: translate() not top / left / margin
  • Resizetransform: scale() not width / height
  • Fadeopacity not visibility or display
  • Rotatetransform: rotate()

will-change: transform can promote an element to its own layer, but use it sparingly and remove it when the animation ends — each promoted layer consumes memory, and applying it broadly makes things slower rather than faster.

Scroll animations, the modern way and the fallback

Scroll-driven animations let CSS respond to scroll position with no JavaScript at all. Support is good in Chromium and improving elsewhere, so treat it as progressive enhancement.

@keyframes fade-up {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

/* Only where supported -- everything else stays visible */
@supports (animation-timeline: view()) {
  .reveal {
    animation: fade-up linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

The @supports wrapper is essential. Without it, browsers that do not understand animation-timeline apply the keyframes immediately, which leaves elements stuck at opacity: 0 — invisible content, and no error to tell you why.

For universal support, Intersection Observer is about ten lines and adds a class when an element enters the viewport:

document.addEventListener('DOMContentLoaded', () => {
  const els = document.querySelectorAll('.reveal');
  if (!('IntersectionObserver' in window)) {
    els.forEach(el => el.classList.add('is-visible'));
    return;
  }

  const io = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      entry.target.classList.add('is-visible');
      io.unobserve(entry.target);   // animate once, then stop watching
    });
  }, { threshold: 0.15, rootMargin: '0px 0px -60px 0px' });

  els.forEach(el => io.observe(el));
});
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.5s ease, transform 0.5s ease;
}
.reveal.is-visible { opacity: 1; transform: none; }

/* Staggered children, without a class per item */
.reveal.is-visible > * { transition-delay: calc(var(--i, 0) * 80ms); }

io.unobserve after the first trigger matters on a long page — without it the callback keeps firing for every element on every scroll.

Respecting reduced motion, which is not optional

Some people get motion sickness from parallax and large movement. The operating system exposes that preference and honouring it is a two-line change.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Note it sets a near-zero duration rather than animation: none. That matters, because an element animating from opacity: 0 needs the animation to complete instantly rather than never run — none would leave it invisible.

The subtler approach is to keep the fade and drop the movement, which preserves the sense of things appearing without the motion that causes problems:

@media (prefers-reduced-motion: reduce) {
  .reveal { transform: none; }   /* fade only, no travel */
}

This is a genuine accessibility requirement rather than a nicety. It is also trivially cheap, which makes skipping it hard to justify.

Not wrecking your Core Web Vitals

Animation is where well-intentioned design meets measurable regression. Three specific traps.

Do not animate anything above the fold on load. Your largest contentful element fading in delays LCP by exactly the animation duration. The hero should be visible immediately; animate what is below it.

/* Reveal only below the fold */
.hero { opacity: 1; transform: none; animation: none; }

Reserve space for everything. An element that animates in from height: 0 pushes content down when it arrives, which is cumulative layout shift. transform does not affect layout, which is another reason to use it — but explicit width and height on images matter just as much.

Watch the interaction cost. Heavy scroll listeners and constant animation raise Interaction to Next Paint. Intersection Observer is cheap because it does not run on every scroll event; a scroll handler recalculating positions is not.

  • Keep durations short — 200-400ms feels responsive, 800ms feels sluggish.
  • Use ease-out for elements entering, ease-in for leaving.
  • Animate a handful of elements per viewport, not everything.
  • Test on a mid-range phone with CPU throttling, not on your laptop.

That last point is the one that changes minds. An animation that is elegant on a development machine can be visibly janky on a four-year-old Android, and that is what most of your visitors are using.

A small, useful set to start from

/* Fade up on reveal */
@keyframes fade-up {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

/* Card lift on hover -- pointer devices only */
@media (hover: hover) {
  .card {
    transition: transform 0.2s ease, box-shadow 0.2s ease;
  }
  .card:hover {
    transform: translateY(-6px);
    box-shadow: 0 12px 24px rgb(0 0 0 / 0.12);
  }
}

/* Underline that grows from the left */
.link-underline {
  background-image: linear-gradient(currentColor, currentColor);
  background-size: 0% 1px;
  background-position: 0 100%;
  background-repeat: no-repeat;
  transition: background-size 0.25s ease;
}
.link-underline:hover { background-size: 100% 1px; }

/* Loading skeleton */
@keyframes shimmer { to { background-position-x: -200%; } }
.skeleton {
  background: linear-gradient(90deg, #eee 40%, #f5f5f5 50%, #eee 60%) 0 0 / 200% 100%;
  animation: shimmer 1.4s linear infinite;
}

@media (hover: hover) is worth using on every hover effect. On touch devices a hover state can stick after a tap, leaving an element visibly in its hovered state until you tap elsewhere. Scoping to pointer devices removes that entirely.

Apply the classes through the block editor’s Additional CSS class(es) field under Advanced, so you are not editing templates to add animations to individual blocks.

One operational note: these are static assets, and the win is that they are small and cacheable. A site serving a lean stylesheet from cache behaves very differently from one loading an animation library plus its dependencies — which is the same argument as everywhere else in WordPress performance, and the WordPress files documentation covers where the child theme assets live if you are editing through the dashboard.

How this fits the rest of the stack

Write the CSS in a child theme, animate only transform and opacity, wrap scroll-driven animations in @supports so unsupported browsers do not hide your content, and honour prefers-reduced-motion. Keep the hero static so LCP is not delayed by your own animation. If you are pricing WordPress hosting and want the site and database as separate line items, the RunxBuild hosting calculator shows them apart.

Useful related references:

FAQ

Do I need a plugin for CSS animations in WordPress?

No. Add the CSS to a child theme stylesheet or to Appearance, Customize, Additional CSS. Animation plugins load a stylesheet and script on every page, which is a lot of overhead for a few fade-ins.

Which CSS properties are safe to animate?

transform and opacity. Both can be handled by the compositor without triggering layout or paint. Animating width, height, top, left, margin, or padding forces layout recalculation on every frame and causes visible stutter.

How do I add scroll-triggered animations without JavaScript?

Use CSS scroll-driven animations with animation-timeline: view(), wrapped in an @supports block. Without that wrapper, browsers lacking support apply the keyframes immediately and leave elements stuck invisible.

Why do my animations hurt my Core Web Vitals score?

Usually because something above the fold animates in, which delays Largest Contentful Paint by the animation duration, or because an element animates height and pushes content down, which is layout shift. Keep the hero static and animate transforms only.

Do I have to support prefers-reduced-motion?

You should. Motion causes genuine discomfort for some people and the fix is a two-line media query. Set a near-zero duration rather than animation: none, so elements that animate from opacity 0 still end up visible.

#how to make cool css animations to wordpress#css animations#scroll animations#prefers-reduced-motion#core web vitals