CalmUI
How To

Particles and Effects

Cheap click sparks and a border runner built from CSS keyframes, not a particle system.

CalmUI has no particle system. These two effects come from ordinary CSS @keyframes, per-element custom properties, and box-shadow/radial-gradient layering, no <script> timer loop and no elements added at runtime.

Spark burst on click

Author a fixed number of spark spans directly in the template: do not create them with ui:for when a click fires. Adding elements is a Store-driven structural change: unless the mount is a single contiguous range that reuses the same element identities, Store updates that mount or unmount a subtree falls back to the full document load, and a fresh burst normally hands each spark a new key. Toggling a class and a few custom properties on spans that already exist takes the cheap attribute patch path instead: the same elements stay mounted, only their class and style attributes change.

<div class="spark-field">
  <div class="spark" :class="sparks[0].active ? 'spark active' : 'spark'" :style="sparks[0].style"></div>
  <div class="spark" :class="sparks[1].active ? 'spark active' : 'spark'" :style="sparks[1].style"></div>
  <!-- repeat through sparks[19] — 20 spans total -->
  <button class="fire-button" @click="burst">Fire</button>
</div>
.spark-field { position: relative; }
.spark {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 6px;
  height: 6px;
  border-radius: 3px;
  background-image: radial-gradient(circle, #fff7d6 0%, #ffb347 60%, transparent 100%);
  opacity: 0;
  pointer-events: none;
}
.spark.active {
  animation: spark-fly 420ms ease-out both;
}
@keyframes spark-fly {
  0%   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0.3); }
}

:class and :style only accept literals, prop/local paths, comparisons and &&/||, never a template literal or string concatenation (see the small binding profile). Build each spark's per-instance --dx, --dy and animation-delay in <script>, then bind the finished object:

import { defineComponent, state } from "@calmui/runtime";

interface Spark { active: boolean; style: Record<string, string | number> }

export default defineComponent(() => {
  const sparks = state<Spark[]>(
    Array.from({ length: 20 }, () => ({ active: false, style: {} })),
  );

  function burst(): void {
    const next = sparks.value.map((spark, index) => {
      const angle = (index / sparks.value.length) * Math.PI * 2;
      const distance = 28 + Math.random() * 24;
      return {
        active: true,
        style: {
          '--dx': `${Math.cos(angle) * distance}px`,
          '--dy': `${Math.sin(angle) * distance}px`,
          animationDelay: `${Math.random() * 40}ms`,
        },
      };
    });
    sparks.value = next;
    // Re-trigger next click even if the previous burst is still animating:
    // clear `active` after the animation's own duration so the next burst's
    // class toggle (false -> true) restarts it instead of being a no-op.
    setTimeout(() => {
      sparks.value = sparks.value.map((spark) => ({ ...spark, active: false }));
    }, 460);
  }

  return { sparks, burst };
});

animation-delay accepts var() references and each spark's keyframes read its own --dx/--dy through var() in transform, so twenty spans sharing one @keyframes spark-fly rule still fly in twenty different directions. This requires RuntimeAnimationVariables (feature bit 15) on the compiled package and the Unreal runtime. See Per-element animation parameters. transform and opacity keyframes are paint-only: unlike a width/height keyframe, they never force a layout recalculation (see Animated layout).

Border runner

A thin dot that runs once around a panel's border, with a short trailing tail, is one absolutely positioned wrapper animating across the full width and a handful of dots inside it with staggered animation-delay:

<div class="runner-track">
  <div class="runner">
    <div class="runner-dot lead"></div>
    <div class="runner-dot trail" style="animation-delay: 60ms"></div>
    <div class="runner-dot trail" style="animation-delay: 120ms"></div>
    <div class="runner-dot trail" style="animation-delay: 180ms"></div>
  </div>
</div>
.runner-track {
  position: relative;
  width: 100%;
  height: 3px;
  overflow: hidden;
}
.runner {
  position: absolute;
  inset: 0;
  animation: runner-sweep 1600ms linear infinite;
}
@keyframes runner-sweep {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}
.runner-dot {
  position: absolute;
  top: 50%;
  width: 5px;
  height: 5px;
  border-radius: 3px;
  transform: translateY(-50%);
  box-shadow: 0 0 6px 2px #ffd76688;
}
.runner-dot.lead {
  left: 0;
  background-image: radial-gradient(circle, #fff7d6 0%, #ffd766 100%);
}
.runner-dot.trail {
  left: 0;
  background-image: radial-gradient(circle, #ffd76699 0%, transparent 100%);
  animation: runner-fade 1600ms linear infinite;
}
@keyframes runner-fade {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 0.15; }
}

Percentage translations resolve against the element's own border-box, so translateX(-100%)/translateX(100%) move .runner fully off each side regardless of the track's actual width (see Transforms and layout). The lead dot and each trail dot share one keyframes rule but start at different points in the loop because their animation-delay values differ; no per-element custom properties are needed here since every dot travels with the same .runner wrapper; only the trail dots' independent fade uses its own staggered animation-delay.

On this page