All loaders

CSS / SVG · 4.7 KB gzip

Ember Flame

Layered fire with a hot core, soft outer glow, and rising sparks.

signatureelementalfireflameembers
Interactive preview

Usage

Download HTML

Packages are @vikast908/core and @vikast908/loaders. Prefer 0.1.1+ (0.1.0 loaders is broken on npm).

npm install @vikast908/loaders@0.1.1 @vikast908/core@0.1.1
import '@vikast908/loaders/ember-flame';

<orbux-ember-flame state="thinking"></orbux-ember-flame>

Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:

<script type="module" src="http://localhost:4321/orbux/ember-flame.js"></script>

<orbux-ember-flame state="thinking"></orbux-ember-flame>

Component source ember-flame.ts

import { OrbuxElement } from '@vikast908/core';

/**
 * Ember Flame: layered fire with a hot core, mid and outer tongues, soft glow,
 * and rising sparks. Organic sway — not cardboard clip-path cutouts.
 */
const STYLES = /* css */ `
:host {
  --_tone: var(--orbux-color, #f97316);
  --_tone-2: var(--orbux-color-2, #facc15);
  --_hot: color-mix(in srgb, var(--_tone-2) 55%, white);
  --_dur: 0.95s;
  --_ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --_ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}

.flame {
  position: relative;
  inline-size: 100%;
  block-size: 100%;
  display: grid;
  place-items: end center;
  overflow: visible;
}

/* Soft ground heat — not a hard drop-shadow plate */
.glow {
  position: absolute;
  inset-inline: 12%;
  inset-block-end: 2%;
  block-size: 28%;
  border-radius: 50%;
  background: radial-gradient(
    ellipse at center,
    color-mix(in srgb, var(--_tone) 55%, transparent) 0%,
    color-mix(in srgb, var(--_tone-2) 22%, transparent) 45%,
    transparent 72%
  );
  opacity: 0.85;
  animation: orbux-ember-glow calc(1.1s * var(--orbux-speed-scale)) var(--_ease-in-out) infinite;
}

/* Shared teardrop volume: pointed top, wide base */
.tongue {
  position: absolute;
  inset-inline-start: 50%;
  inset-block-end: 10%;
  transform: translateX(-50%);
  transform-origin: 50% 100%;
  border-radius: 50% 50% 42% 42% / 68% 68% 28% 28%;
  will-change: transform, opacity;
}

/* Cool outer envelope — soft, slightly blurred edge */
.outer {
  inline-size: 52%;
  block-size: 72%;
  background: radial-gradient(
    ellipse 70% 90% at 50% 78%,
    color-mix(in srgb, var(--_tone) 88%, transparent) 0 38%,
    color-mix(in srgb, var(--_tone) 45%, transparent) 62%,
    transparent 78%
  );
  filter: blur(1.2px);
  opacity: 0.92;
  animation: orbux-ember-sway-a calc(var(--_dur) * 1.15 * var(--orbux-speed-scale)) var(--_ease-in-out) infinite;
}

/* Mid body — hotter, tighter */
.mid {
  inline-size: 38%;
  block-size: 62%;
  background: radial-gradient(
    ellipse 65% 85% at 48% 72%,
    var(--_tone-2) 0 22%,
    var(--_tone) 48%,
    color-mix(in srgb, var(--_tone) 35%, transparent) 72%,
    transparent 86%
  );
  filter: blur(0.4px);
  opacity: 0.95;
  animation: orbux-ember-sway-b calc(var(--_dur) * var(--orbux-speed-scale)) var(--_ease-in-out) infinite;
  animation-delay: calc(-0.12s * var(--orbux-speed-scale));
  mix-blend-mode: screen;
}

/* Hot core — white-yellow heart of the fire */
.core {
  inline-size: 24%;
  block-size: 48%;
  background: radial-gradient(
    ellipse 60% 80% at 50% 70%,
    white 0 12%,
    var(--_hot) 28%,
    var(--_tone-2) 55%,
    color-mix(in srgb, var(--_tone) 40%, transparent) 78%,
    transparent 92%
  );
  opacity: 0.98;
  animation: orbux-ember-sway-c calc(var(--_dur) * 0.85 * var(--orbux-speed-scale)) var(--_ease-in-out) infinite;
  animation-delay: calc(-0.22s * var(--orbux-speed-scale));
  mix-blend-mode: screen;
}

/* Small tip lick above the core */
.tip {
  inline-size: 14%;
  block-size: 28%;
  inset-block-end: 48%;
  background: radial-gradient(
    ellipse at 50% 80%,
    color-mix(in srgb, var(--_hot) 80%, white) 0 30%,
    color-mix(in srgb, var(--_tone-2) 55%, transparent) 60%,
    transparent 85%
  );
  filter: blur(0.6px);
  opacity: 0.9;
  animation: orbux-ember-tip calc(var(--_dur) * 0.7 * var(--orbux-speed-scale)) var(--_ease-in-out) infinite;
  mix-blend-mode: screen;
}

/* Rising sparks */
.spark {
  position: absolute;
  inset-inline-start: calc(32% + var(--_x));
  inset-block-end: 22%;
  inline-size: calc(3.5% + var(--_s, 0%));
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 40% 35%, white, var(--_tone-2) 45%, transparent 75%);
  opacity: 0;
  animation: orbux-ember-lift calc((1.15s + var(--_i) * 0.07s) * var(--orbux-speed-scale)) var(--_ease-out) infinite;
  animation-delay: calc(var(--_i) * -0.18s * var(--orbux-speed-scale));
  pointer-events: none;
}

@keyframes orbux-ember-sway-a {
  0%, 100% { transform: translateX(-50%) scale(0.94, 1) rotate(-4deg); opacity: 0.78; }
  40% { transform: translateX(-50%) scale(1.04, 0.96) rotate(5deg); opacity: 1; }
  70% { transform: translateX(-50%) scale(0.97, 1.02) rotate(-2deg); opacity: 0.9; }
}
@keyframes orbux-ember-sway-b {
  0%, 100% { transform: translateX(-50%) scale(0.96, 1.02) rotate(3deg); }
  50% { transform: translateX(-50%) scale(1.05, 0.94) rotate(-5deg); }
}
@keyframes orbux-ember-sway-c {
  0%, 100% { transform: translateX(-50%) scale(0.92, 1.04) rotate(-2deg); opacity: 0.88; }
  50% { transform: translateX(-50%) scale(1.06, 0.92) rotate(3deg); opacity: 1; }
}
@keyframes orbux-ember-tip {
  0%, 100% { transform: translateX(-50%) scale(0.85, 1) rotate(-8deg); opacity: 0.55; }
  50% { transform: translateX(-50%) scale(1.1, 0.88) rotate(10deg); opacity: 1; }
}
@keyframes orbux-ember-glow {
  0%, 100% { transform: scaleX(0.92); opacity: 0.65; }
  50% { transform: scaleX(1.06); opacity: 0.95; }
}
@keyframes orbux-ember-lift {
  0% { transform: translateY(0) translateX(0) scale(0.85); opacity: 0; }
  12% { opacity: 0.95; }
  100% { transform: translateY(-520%) translateX(var(--_drift, 40%)) scale(0.35); opacity: 0; }
}
@keyframes orbux-ember-done {
  0% { transform: scale(1); }
  50% { transform: scale(1.06); }
  100% { transform: scale(1); }
}
@keyframes orbux-ember-error {
  0%, 100% { transform: translateX(0); }
  30% { transform: translateX(-3%); }
  60% { transform: translateX(3%); }
}

/* ---- states ---- */
:host([data-state="thinking"]) { --_dur: 0.95s; }
:host([data-state="streaming"]) { --_dur: 0.55s; }
:host([data-state="streaming"]) .glow {
  opacity: 1;
  filter: blur(0.5px);
}
:host([data-state="streaming"]) .outer,
:host([data-state="streaming"]) .mid,
:host([data-state="streaming"]) .core {
  filter: saturate(1.15) brightness(1.06);
}

/* Mechanical “tool” feel without cardboard steps on organic fire */
:host([data-state="tool-calling"]) { --_dur: 0.72s; }
:host([data-state="tool-calling"]) .outer { animation-duration: calc(0.55s * var(--orbux-speed-scale)); }
:host([data-state="tool-calling"]) .mid { animation-duration: calc(0.42s * var(--orbux-speed-scale)); }
:host([data-state="tool-calling"]) .tip { animation-duration: calc(0.35s * var(--orbux-speed-scale)); }

:host([data-state="idle"]) .outer,
:host([data-state="idle"]) .mid,
:host([data-state="idle"]) .core,
:host([data-state="idle"]) .tip,
:host([data-state="idle"]) .glow,
:host([data-state="idle"]) .spark { animation: none; }
:host([data-state="idle"]) .flame { opacity: 0.48; }
:host([data-state="idle"]) .outer { transform: translateX(-50%) scale(0.88, 0.82); opacity: 0.55; }
:host([data-state="idle"]) .mid { transform: translateX(-50%) scale(0.9, 0.85); }
:host([data-state="idle"]) .core { transform: translateX(-50%) scale(0.92, 0.88); opacity: 0.7; }
:host([data-state="idle"]) .tip { opacity: 0.25; transform: translateX(-50%) scale(0.8); }
:host([data-state="idle"]) .glow { opacity: 0.35; transform: scaleX(0.85); }

:host([data-state="waiting"]) .outer,
:host([data-state="waiting"]) .mid,
:host([data-state="waiting"]) .core,
:host([data-state="waiting"]) .tip,
:host([data-state="waiting"]) .glow,
:host([data-state="waiting"]) .spark { animation: none; }
:host([data-state="waiting"]) .flame { opacity: 0.82; }
:host([data-state="waiting"]) .outer { transform: translateX(-50%) scale(0.92, 0.78); opacity: 0.7; }
:host([data-state="waiting"]) .mid { transform: translateX(-50%) scale(0.94, 0.8); }
:host([data-state="waiting"]) .core { transform: translateX(-50%) scale(0.9, 0.82); opacity: 0.85; }
:host([data-state="waiting"]) .tip { opacity: 0.4; transform: translateX(-50%) scale(0.75) rotate(-6deg); }
:host([data-state="waiting"]) .glow { opacity: 0.55; }

:host([data-state="done"]) {
  --_tone: var(--orbux-color-success, #22c55e);
  --_tone-2: #bbf7d0;
  --_hot: #ecfdf5;
}
:host([data-state="error"]) {
  --_tone: var(--orbux-color-error, #ef4444);
  --_tone-2: #fbbf24;
  --_hot: #fff7ed;
}
:host([data-state="done"]) .outer,
:host([data-state="error"]) .outer,
:host([data-state="done"]) .mid,
:host([data-state="error"]) .mid,
:host([data-state="done"]) .core,
:host([data-state="error"]) .core,
:host([data-state="done"]) .tip,
:host([data-state="error"]) .tip,
:host([data-state="done"]) .glow,
:host([data-state="error"]) .glow,
:host([data-state="done"]) .spark,
:host([data-state="error"]) .spark { animation: none; }
:host([data-state="done"]) .flame { animation: orbux-ember-done 220ms var(--_ease-out) 1; }
:host([data-state="error"]) .flame { animation: orbux-ember-error 200ms var(--_ease-out) 1; }
:host([data-settled="true"]) .flame { animation: none; }

:host([data-paused="true"]) .outer,
:host([data-paused="true"]) .mid,
:host([data-paused="true"]) .core,
:host([data-paused="true"]) .tip,
:host([data-paused="true"]) .glow,
:host([data-paused="true"]) .spark { animation-play-state: paused; }

:host([data-reduced-motion="true"]) .outer,
:host([data-reduced-motion="true"]) .mid,
:host([data-reduced-motion="true"]) .core,
:host([data-reduced-motion="true"]) .tip,
:host([data-reduced-motion="true"]) .glow,
:host([data-reduced-motion="true"]) .spark { animation: none !important; }
:host([data-reduced-motion="true"]) .outer { transform: translateX(-50%); opacity: 0.85; }
:host([data-reduced-motion="true"]) .mid { transform: translateX(-50%); }
:host([data-reduced-motion="true"]) .core { transform: translateX(-50%); }
:host([data-reduced-motion="true"]) .tip { transform: translateX(-50%); opacity: 0.7; }
:host([data-reduced-motion="true"][data-state="streaming"]) .glow { opacity: 1; }
:host([data-reduced-motion="true"][data-state="tool-calling"]) .outer { transform: translateX(-50%) scale(1.05, 0.92); }
:host([data-reduced-motion="true"][data-state="waiting"]) .outer,
:host([data-reduced-motion="true"][data-state="waiting"]) .mid,
:host([data-reduced-motion="true"][data-state="waiting"]) .core {
  transform: translateX(-50%) scale(0.92, 0.8);
  opacity: 0.7;
}
:host([data-reduced-motion="true"][data-state="idle"]) .flame { opacity: 0.5; }
`;

export class OrbuxEmberFlame extends OrbuxElement {
  protected build(): void {
    const style = document.createElement('style');
    style.textContent = STYLES;
    const flame = document.createElement('div');
    flame.className = 'flame';
    flame.setAttribute('part', 'flame');
    const sparks = [
      { i: 0, x: '0%', s: '0%', drift: '28%' },
      { i: 1, x: '18%', s: '1%', drift: '-22%' },
      { i: 2, x: '8%', s: '0%', drift: '48%' },
      { i: 3, x: '28%', s: '1.5%', drift: '-36%' },
      { i: 4, x: '12%', s: '0%', drift: '18%' },
    ];
    flame.innerHTML =
      '<span class="glow" part="glow"></span>' +
      '<span class="tongue outer" part="outer"></span>' +
      '<span class="tongue mid" part="mid"></span>' +
      '<span class="tongue core" part="core"></span>' +
      '<span class="tongue tip" part="tip"></span>' +
      sparks
        .map(
          (s) =>
            `<i class="spark" style="--_i:${s.i};--_x:${s.x};--_s:${s.s};--_drift:${s.drift}" part="spark"></i>`,
        )
        .join('');
    this.root.append(style, flame);
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-ember-flame')) {
  customElements.define('orbux-ember-flame', OrbuxEmberFlame);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-ember-flame': OrbuxEmberFlame;
  }
}

Depends on @vikast908/core. Paste the source, add the base element, and set state from your agent code.

Attributes & properties

NameTypeDescription
stateAgentState idle · thinking · streaming · tool-calling · waiting · done · error
sizeCSS lengthOverall square size.
--orbux-widthCSS lengthOptional inline-size override.
--orbux-heightCSS lengthOptional block-size override.
speednumberAnimation speed multiplier (1 = default).
labelstringAccessible label (defaults per state).
pausedbooleanFreeze the animation.

Respects prefers-reduced-motion, exposes status/progressbar semantics with a live label, and auto-pauses (sets data-paused) when offscreen or the tab is hidden.