How to Make a “Smoke” Effect with CSS

You need gentle, drifting smoke for a UI scene: a steaming mug, a tiny chimney, or a bonfire banner. This guide builds a convincing smoke effect with pure CSS. No images, no SVG filters, no JavaScript. By the end, you will have layered puffs that rise, blur, fade, and drift with subtle randomness, all driven by custom properties you can tweak in seconds.

Why a CSS Smoke Effect Matters

A CSS-only smoke effect cuts HTTP requests and keeps your assets fully themeable in code. You avoid raster images that look wrong when scaled, and you keep everything in one stylesheet for easy maintenance. Since the effect is built from primitives like circles and ovals, you can refine edges, colors, and motion with a few variables. This also gives you portable snippets you can drop into components, loaders, or hero art without changing your build pipeline.

Prerequisites

This walkthrough assumes you are comfortable writing HTML and CSS, and that you can read custom properties and pseudo-elements. No JavaScript is required.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup is minimal. A scene wrapper holds an emitter and a batch of repeating puff elements. Each puff animates on its own timing. The wrapper is marked as decorative for screen readers, which is perfect for background smoke that does not convey meaning.

<!-- HTML -->
<main class="demo-wrap">
  <div class="smoke-scene" aria-hidden="true">
    <div class="emitter"></div>

    <!-- Repeatable smoke puffs -->
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
    <span class="smoke"></span>
  </div>
</main>

The .smoke-scene sets the coordinate space. The .emitter marks where smoke starts. Each .smoke element becomes a soft puff that rises and fades. Twelve puffs create a continuous loop without noticeable gaps.

Step 2: The Basic CSS & Styling

Start with variables for sizing, timing, and colors. Then center the scene on the page and draw a simple emitter. This gives the smoke a clear origin. The background uses a subtle gradient so smoke edges are visible.

/* CSS */
:root {
  --bg-1: #0f1117;
  --bg-2: #1a1e27;
  --emitter: #5b6b7a;
  --smoke-rgb: 245 245 245; /* smoke color as RGB for alpha control */
  --scene-w: 280px;
  --scene-h: 260px;

  /* motion knobs */
  --rise: 200px;    /* vertical travel */
  --base-dur: 5.5s; /* average duration */
  --blur-start: 2px;
  --blur-end: 7px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100svh;
  display: grid;
  place-items: center;
  background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
  color: #c7c9d3;
}

.demo-wrap {
  padding: 24px;
}

.smoke-scene {
  position: relative;
  width: var(--scene-w);
  height: var(--scene-h);
  border-radius: 14px;
  background: radial-gradient(120% 100% at 50% 90%, #0f111780 0%, #0f1117 40%, #131722 100%);
  overflow: hidden;
  box-shadow: 0 10px 30px #00000066, inset 0 0 0 1px #ffffff0a;
  isolation: isolate; /* keep blend effects contained */
}

/* a simple emitter base (like a vent or cup rim) */
.emitter {
  position: absolute;
  left: 50%;
  bottom: 18px;
  width: 90px;
  height: 18px;
  transform: translateX(-50%);
  border-radius: 10px;
  background: linear-gradient(#6a7a8a, var(--emitter));
  box-shadow: 0 6px 10px #00000066;
}

Advanced Tip: Centralize motion and color in variables. You can theme smoke (e.g., darker exhaust vs. light steam) by changing one token. Sharing variables between components keeps your scene consistent.

Step 3: Building the Puff

A puff is a soft circle that grows, drifts, and fades. Layer two extra circles with pseudo-elements to break symmetry. The shape remains lightweight and still uses standard properties that composite well. The background of each circle uses a radial gradient with a transparent falloff for soft edges.

/* CSS */
.smoke {
  position: absolute;
  left: 50%;
  bottom: 44px; /* just above the emitter */
  width: var(--size, 22px);
  height: var(--size, 22px);
  transform: translateX(-50%) translateY(0) scale(0.7);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  filter: blur(var(--blur, var(--blur-start)));
  will-change: transform, opacity, filter;

  /* soft-edged puff core */
  background:
    radial-gradient(60% 60% at 50% 50%, rgb(var(--smoke-rgb) / .55) 0%, rgb(var(--smoke-rgb) / 0) 65%);

  /* animation knobs per puff */
  --dx: 0px;        /* horizontal drift amount */
  --spin: 0deg;     /* small rotation for irregularity */
  --scale-end: 1.6; /* growth */
  --dur: var(--base-dur);
  --delay: 0s;

  animation: rise var(--dur) ease-out var(--delay) infinite;
}

/* add two lobe circles to break the perfect circle outline */
.smoke::before,
.smoke::after {
  content: "";
  position: absolute;
  border-radius: 50%;
  inset: 0;
  background: radial-gradient(65% 65% at 50% 50%, rgb(var(--smoke-rgb) / .5) 0%, rgb(var(--smoke-rgb) / 0) 68%);
}

/* offset lobes to make an organic "blob" */
.smoke::before {
  width: 70%;
  height: 70%;
  left: -15%;
  top: 10%;
}

.smoke::after {
  width: 55%;
  height: 55%;
  right: -10%;
  top: -5%;
}

/* the rise animation handles drift, growth, fade, and blur */
@keyframes rise {
  0% {
    transform: translate(-50%, 0) scale(0.7) rotate(0deg);
    opacity: 0;
    filter: blur(var(--blur-start));
  }
  12% {
    opacity: .6;
  }
  60% {
    opacity: .45;
  }
  100% {
    transform: translate(calc(-50% + var(--dx)), calc(-1 * var(--rise))) scale(var(--scale-end)) rotate(var(--spin));
    opacity: 0;
    filter: blur(var(--blur-end));
  }
}

How This Works (Code Breakdown)

The puff starts as a circle with border-radius: 50%. If you need a refresher on circular primitives, see how to make a circle with CSS. The radial-gradient background creates a bright center that fades to transparent, which avoids hard edges. A small blur adds airiness.

Pseudo-elements build two smaller circles that overlap the base circle. Offsetting them horizontally and vertically breaks the symmetry and produces a soft, compound contour. This gives a natural, blobby edge without heavy filters. If you want more organic motion, the approach mirrors the idea behind a CSS blob, but here it is just layered circles for performance and clarity.

The rise keyframes animate translation, scale, rotation, opacity, and blur. The transform-centric animation stays on the compositor for smooth frames. Opacity fades the puff in and back out. Increasing the blur as it rises sells the diffusion you see in real smoke. Horizontal drift via –dx gives each puff a gentle lean to one side. You can swap the base circle for an oval by tweaking width and height independently; if you want the underlying math, review how to make an oval with CSS.

Step 4: Orchestrating Many Puffs

Single puffs look fine, but smoke reads best with overlapping trails that launch at staggered times. The nth-of-type selectors assign unique variables per puff. This offsets delay, duration, size, drift, and rotation to avoid a visible loop.

/* CSS */
.smoke:nth-of-type(1)  { --size: 18px; --dx: -10px; --spin: -8deg;  --dur: 5.0s; --delay: 0s;    --scale-end: 1.7; }
.smoke:nth-of-type(2)  { --size: 22px; --dx: 6px;   --spin: 7deg;   --dur: 5.6s; --delay: -1.6s; --scale-end: 1.65; }
.smoke:nth-of-type(3)  { --size: 16px; --dx: -7px;  --spin: 5deg;   --dur: 5.2s; --delay: -3.2s; --scale-end: 1.55; }
.smoke:nth-of-type(4)  { --size: 20px; --dx: 9px;   --spin: -6deg;  --dur: 6.0s; --delay: -2.2s; --scale-end: 1.8; }
.smoke:nth-of-type(5)  { --size: 19px; --dx: -4px;  --spin: 4deg;   --dur: 5.4s; --delay: -0.8s; --scale-end: 1.6; }
.smoke:nth-of-type(6)  { --size: 23px; --dx: 8px;   --spin: -10deg; --dur: 6.2s; --delay: -2.8s; --scale-end: 1.85; }
.smoke:nth-of-type(7)  { --size: 17px; --dx: -5px;  --spin: -4deg;  --dur: 5.3s; --delay: -4.1s; --scale-end: 1.65; }
.smoke:nth-of-type(8)  { --size: 21px; --dx: 10px;  --spin: 9deg;   --dur: 6.1s; --delay: -1.1s; --scale-end: 1.8; }
.smoke:nth-of-type(9)  { --size: 18px; --dx: -9px;  --spin: 6deg;   --dur: 5.8s; --delay: -3.6s; --scale-end: 1.7; }
.smoke:nth-of-type(10) { --size: 15px; --dx: 5px;   --spin: -3deg;  --dur: 5.1s; --delay: -2.5s; --scale-end: 1.55; }
.smoke:nth-of-type(11) { --size: 20px; --dx: -6px;  --spin: 8deg;   --dur: 5.7s; --delay: -0.4s; --scale-end: 1.75; }
.smoke:nth-of-type(12) { --size: 22px; --dx: 7px;   --spin: -7deg;  --dur: 6.0s; --delay: -3.0s; --scale-end: 1.85; }

/* hover: quick stress test, speeds up and adds volume */
.smoke-scene:hover .smoke {
  --rise: 230px;
  --blur-end: 8px;
}

How This Works (Code Breakdown)

Each nth-of-type rule sets a custom property bundle. The delays are negative so the animation loop starts mid-way, which prevents a synchronized “all start at once” moment on load. Varying duration prevents a repeating cadence that the eye can catch. Scale, drift, and rotation variation produce a wobble you expect from rising air.

The hover rule is optional. It shows how you can push more height and diffusion with one change. You can wire the same overrides to a class like .is-windy on the scene to react to app state. If you need a taller plume, increase –rise and slightly widen sizes to keep proportions believable.

Advanced Techniques: Adding Animations & Hover Effects

Smoke looks richer when it curls. You can add a gentle sideways oscillation and a faint hue tint for environments like neon signage or a campfire UI. The curl is a second animation that nudges horizontal position and rotation without fighting the rise motion. Apply animation-composition: accumulate where supported for additive transforms; as a fallback, split oscillation into translateX only.

/* CSS */
@keyframes curl {
  0%   { transform: translateX(-50%) translateY(0) rotate(0deg); }
  25%  { transform: translateX(calc(-50% - 4px)) translateY(0) rotate(-2deg); }
  50%  { transform: translateX(calc(-50% + 3px))  translateY(0) rotate(2deg); }
  75%  { transform: translateX(calc(-50% - 5px)) translateY(0) rotate(-1deg); }
  100% { transform: translateX(-50%) translateY(0) rotate(0deg); }
}

/* layer curl on top of rise */
.smoke {
  animation:
    rise var(--dur) ease-out var(--delay) infinite,
    curl 2.6s ease-in-out var(--delay) infinite;
}

/* optional tint for stylized scenes */
.smoke {
  /* mix-blend-mode helps smoke glow on darker backgrounds */
  mix-blend-mode: screen;
}

/* respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
  .smoke {
    animation: none;
    opacity: .12;
    filter: blur(4px);
    transform: translateX(-50%) translateY(-30px) scale(1.2);
  }
}

The curl keyframes modify horizontal position and rotation in a slow loop. When combined with rise, the result is a wobble that looks like air currents. mix-blend-mode: screen lifts smoke brightness over dark scenes without oversaturating color. The prefers-reduced-motion query reduces motion for users who need a calmer experience, while still showing a subtle hint of smoke.

Accessibility & Performance

A visual flourish still needs careful attention to users and to rendering cost. Keep the effect decorative unless it communicates meaning. If it does communicate meaning, label it clearly and expose fallback content.

Accessibility

Mark decorative smoke with aria-hidden=”true” so screen readers skip it. If smoke conveys state (for example, a “brewing” icon), use role=”img” and an aria-label on the wrapper like aria-label=”Brewing steam”. Respect motion sensitivity. The prefers-reduced-motion example above turns the loop into a static hint. Offer a deterministic class to disable the effect altogether when your app has a “reduce visuals” toggle.

Performance

The core animation uses transform and opacity, which the compositor handles well. filter: blur is more expensive than transform, so keep the element count moderate. Twelve puffs run well on modern devices. If you need more volume, grow each puff instead of adding more nodes. will-change on .smoke hints at animated properties for smoother frames. Avoid animating box-shadow or large paints. Test on low-end devices and consider capping the scene to one smoke-scene per page for heavy layouts.

Smoke That Fits Your Scene

You built a CSS smoke effect with layered radial gradients, subtle blur, and two animations that rise and curl. The look is adjustable through variables for size, height, drift, and timing, and it plays well with dark or light themes. Now you can drop this plume behind a mug, a chimney, or any component built from simple shapes, and refine it using the same building blocks you use to make a circle with CSS, make an oval with CSS, or even craft a more organic CSS blob. Tune the variables, adjust the count, and build the exact mood your UI needs.

Leave a Comment