Skip to content
SiteEmail

This article covers the three primary ways to animate your game UI in Gameface: CSS transitions for simple state changes, CSS keyframe animations for multi-step or looping sequences, and JavaScript playback control for precise timeline management. You will learn when to reach for each tool, how @starting-style solves mounting animations without any JavaScript, and how to use Gameface’s extended Web Animations API for runtime control.

In standard web development, developers often reach for JavaScript-based animation solutions, whether through requestAnimationFrame loops, tweening libraries, or direct style manipulation. In Gameface, this approach carries a measurable performance cost.

CSS animations in Gameface are evaluated natively inside the engine’s rendering pipeline. The animation math, interpolation, and property updates all happen outside of JavaScript. The JS thread remains free to handle game logic, data-binding updates, and user input processing.

JavaScript-driven animations, by contrast, run on the main JS thread. Every frame requires a script callback to compute the next value and write it to the DOM, which competes directly with your other game UI logic for execution time.

The practical hierarchy for choosing an animation approach in Gameface is:

  1. CSS Transitions for simple, reactive state changes (A to B).
  2. CSS Keyframe Animations for multi-step sequences, loops, or auto-playing effects.
  3. JavaScript control only when you need runtime playback management (pause, seek, or scrub).

CSS transitions are the simplest animation tool available. They animate a property from its current value to a new target value whenever that property changes, typically in response to a class toggle, a pseudo-state like :hover, or a JavaScript style update.

The transition shorthand accepts four values:

/* property | duration | easing | delay */
transition: background 200ms ease-out 0ms;
  • property specifies which CSS property to animate (or all to watch every animatable change).
  • duration sets how long the interpolation takes.
  • easing defines the acceleration curve (ease, ease-in, ease-out, linear, cubic-bezier()).
  • delay adds a wait before the animation starts.

You can declare multiple transitions in a single rule by separating them with commas.

A common use case is a button that provides visual feedback on interaction. The button starts in its idle state, and a class or pseudo-state triggers the transition to its active appearance.

HudActions.tsx
import Flex from '@components/Layout/Flex/Flex';
import Button from '@components/Basic/Button/Button';
const HudActions = () => (
<Flex class="hud-actions">
<Button class="action-btn">Equip</Button>
<Button class="action-btn">Drop</Button>
<Button class="action-btn action-btn--active">Use</Button>
</Flex>
);

The transition is declared on the base class. This tells the engine which properties to watch and how to interpolate them whenever their computed values change:

action-button.css
.action-btn {
background: rgba(255, 255, 255, 0.06);
color: #8a939c;
padding: 1.2vh 3vh;
border: 0.15vh solid rgba(255, 255, 255, 0.1);
font-size: 2vh;
cursor: pointer;
transition:
background 200ms ease,
color 200ms ease,
border-color 200ms ease;
}
.action-btn:hover {
background: rgba(78, 205, 196, 0.15);
color: #ffffff;
border-color: rgba(78, 205, 196, 0.5);
}
.action-btn--active,
.action-btn--active:hover {
background: rgba(78, 205, 196, 0.25);
color: #ffffff;
border-color: #4ecdc4;
}

When the user hovers over a button, the engine detects that background, color, and border-color have new target values and smoothly interpolates from the old values to the new ones over 200 milliseconds . Removing the hover state reverses the animation automatically because the target values revert to their base rule values.

A JavaScript toggle can apply the same effect programmatically:

HudActions.tsx
import { createSignal } from 'solid-js';
import Flex from '@components/Layout/Flex/Flex';
import Button from '@components/Basic/Button/Button';
const ACTIONS = ['Equip', 'Drop', 'Use'];
const HudActions = () => {
const [activeAction, setActiveAction] = createSignal<string | null>('Use');
return (
<Flex class="hud-actions">
{ACTIONS.map((action) => (
<Button
class={`action-btn${activeAction() === action ? ' action-btn--active' : ''}`}
onClick={() => setActiveAction(action)}
>
{action}
</Button>
))}
</Flex>
);
};

The result of the above code will look like this:

There are a few engine-specific behaviors to be aware of when working with transitions:

While transitions handle simple A-to-B changes, some UI effects require multiple intermediate steps, looping behavior, or automatic playback without a trigger. These are the cases where CSS @keyframes animations are the right tool.

The key differences from transitions are:

  • Multi-step control. Keyframes define an arbitrary number of intermediate states using percentage stops or from/to shorthand.
  • Auto-play. A keyframe animation starts as soon as its rule is applied to an element. It does not need a property change to trigger it.
  • Looping. The animation-iteration-count property supports infinite repetition, which is common for ambient UI effects.
  • Fill behavior. The animation-fill-mode property controls whether animated values persist after the animation ends (forwards), apply before it starts (backwards), or both.

The animation shorthand follows this order:

animation: <name> <duration> <easing> <delay> <iteration-count> <direction> <fill-mode>;

A common game UI pattern is an ability slot that pulses or glows when the ability is ready. The glow loops continuously until the player activates it, at which point the animation class is removed.

AbilityBar.tsx
import { createSignal } from 'solid-js';
import Flex from '@components/Layout/Flex/Flex';
import Block from '@components/Layout/Block/Block';
const AbilityBar = () => {
const [abilityReady, setAbilityReady] = createSignal(true);
return (
<Flex class="ability-bar">
<Block class={`ability-slot${abilityReady() ? ' ability-slot--ready' : ' ability-slot--idle'}`} />
</Flex>
);
};

The keyframe animation defines three stops that create a continuous pulse cycle: start dim, peak bright, return to dim.

ability-indicator.css
@keyframes pulse-glow {
0% {
box-shadow: 0 0 0.8vh rgba(78, 205, 196, 0.2);
border-color: rgba(78, 205, 196, 0.3);
}
50% {
box-shadow: 0 0 2.5vh rgba(78, 205, 196, 0.6);
border-color: rgba(78, 205, 196, 0.9);
}
100% {
box-shadow: 0 0 0.8vh rgba(78, 205, 196, 0.2);
border-color: rgba(78, 205, 196, 0.3);
}
}
.ability-slot {
width: 8vh;
height: 8vh;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border: 0.2vh solid rgba(255, 255, 255, 0.1);
border-radius: 0.8vh;
font-size: 3.5vh;
}
.ability-slot--ready {
/* Loops forever with a smooth breathing effect */
animation: pulse-glow 2s ease-in-out infinite;
}

The animation starts as soon as the ability-slot--ready class is applied and loops with infinite. The ease-in-out easing produces a smooth breathing rhythm. To stop the glow when the player uses the ability, remove the class. To restart it after a cooldown, add it back:

The result of the above code will look like this:

Keyframes also handle entrance effects that need more than two states. A notification badge that scales up with an overshoot, then settles to its final size, is a classic three-step pattern:

import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const NotificationBadge = () => (
<Block class="notification-badge">
<TextBlock>Quest Complete!</TextBlock>
</Block>
);
notification-badge.css
@keyframes badge-entrance {
0% {
opacity: 0;
transform: scale(0.5);
}
60% {
opacity: 1;
transform: scale(1.08); /* Slight overshoot past the target size */
}
100% {
opacity: 1;
transform: scale(1); /* Settle to the final size */
}
}
.hud-notification {
background: rgba(78, 205, 196, 0.15);
border: 0.15vh solid #4ecdc4;
border-radius: 0.6vh;
padding: 1vh 2.5vh;
color: #ffffff;
font-size: 1.8vh;
animation: badge-entrance 400ms ease-out forwards;
}

The forwards fill mode keeps the final keyframe values applied after the animation ends. Without it, the element would snap back to its pre-animation state.

The result of the above code will look like this:

As noted in the transitions section, standard CSS transitions fail to trigger when an element is newly inserted into the DOM or when its display property changes from none to a visible value. The engine skips the transition because there is no prior computed style to interpolate from.

Before @starting-style, the standard workaround required a two-step JavaScript approach: insert the element with its initial styles, force a layout recalculation, then apply the target styles in a separate step. This works, but it adds JavaScript complexity and introduces a layout flush.

@starting-style solves this entirely in CSS. It defines a set of property values that the engine treats as the “from” state specifically when an element first appears. Combined with a transition on those properties, the element animates from the starting values to its declared state with zero JavaScript animation logic.

The rule has a straightforward structure. Inside the @starting-style block, you write selectors and properties just like a normal stylesheet. These values are only applied once, at the moment the element first matches the selector (on DOM insertion or a display change). After that initial frame, the regular styles take over and the declared transition animates between the two states.

/* The element's final (visible) state */
.modal {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease,
transform 300ms ease;
}
/* Applied only on first appearance, then immediately overridden by the rules above */
@starting-style {
.modal {
opacity: 0;
transform: translateY(2vh);
}
}

When the engine encounters .modal for the first time, it reads the @starting-style values as the initial computed style and the regular .modal rule as the target. Because transition is declared on both opacity and transform, the engine interpolates between them.

To trigger the animation, simply insert the element into the DOM. No JavaScript animation logic is needed:

starting-style-modal.js
const modal = document.getElementById("quest-modal");
function showQuestModal() {
document.body.appendChild(modal);
}
function hideQuestModal() {
modal.remove();
}
document.getElementById("accept-btn").addEventListener("click", hideQuestModal);
document.getElementById("decline-btn").addEventListener("click", hideQuestModal);

A full example of the starting style modal looks like this:

You can also use @starting-style to transition toward the initial (default) value of a property. In this case, you do not need to explicitly set the target in the element’s regular rule, because the engine uses the property’s initial value as the target:

implicit-target.css
.fade-element {
/* opacity defaults to 1, so no explicit declaration is needed */
transition: opacity 300ms ease;
}
@starting-style {
.fade-element {
opacity: 0;
}
}

There are a few constraints to keep in mind when using @starting-style:

CSS handles the majority of game UI animation needs, but there are scenarios where you need runtime playback control. Pausing all UI animations when the game menu opens, seeking to a specific point in a progress-driven animation, or synchronizing a timeline to a game event all require JavaScript intervention.

Gameface supports a subset of the Web Animations API , which gives programmatic access to CSS animations already running on DOM elements. The key principle is that you define the animation in CSS (where it benefits from native performance) and use JavaScript only to control its playback state.

The Node.getAnimations() method returns an array of all resolved animations on a given element. This includes both CSS @keyframes animations and CSS transitions that are currently active.

Here is a charging status bar with a multi-phase keyframe animation that we will control from JavaScript:

ChargeBar.tsx
import { onMount } from 'solid-js';
import Block from '@components/Layout/Block/Block';
import Button from '@components/Basic/Button/Button';
const ChargeBar = () => {
let fillRef!: HTMLElement;
onMount(() => {
// Web Animations API: fillRef.getAnimations()[0].play() etc.
});
return (
<>
<Block class="charge-bar">
<Block ref={fillRef} class="charge-bar__fill" />
</Block>
<Button onClick={() => fillRef.getAnimations()[0]?.play()}>Play</Button>
<Button onClick={() => fillRef.getAnimations()[0]?.pause()}>Pause</Button>
<Button onClick={() => { const a = fillRef.getAnimations()[0]; if (a) { a.currentTime = 0; a.pause(); } }}>Reset</Button>
</>
);
};

The keyframe animation defines the full charging sequence as a single timeline. The bar fills in phases, with the color shifting at each stage:

js-control.css
@keyframes charge-sequence {
0% {
width: 0%;
background: #3a3a4a;
}
50% {
width: 50%;
background: #4ecdc4;
}
80% {
width: 80%;
background: #f2a34e;
}
100% {
width: 100%;
background: #4ef28b;
}
}
.charge-bar__track {
width: 30vh;
height: 3vh;
background: rgba(255, 255, 255, 0.08);
border-radius: 0.4vh;
overflow: hidden;
}
.charge-bar__fill {
height: 100%;
border-radius: 0.4vh;
animation: charge-sequence 4s ease-in-out forwards paused; /* Start paused for JS control */
}
.charge-bar__label {
color: #a0aab4;
font-size: 1.4vh;
margin-top: 0.5vh;
display: block;
}

With the animation defined in CSS, use getAnimations() to retrieve the animation object and control it:

js-control.js
const fill = document.getElementById("charge-fill");
const label = document.getElementById("charge-label");
const animations = fill.getAnimations();
const chargeAnim = animations[0];
// PLAY
document.getElementById("play-btn").addEventListener("click", () => {
chargeAnim.play();
label.textContent = "Charging...";
});
// PAUSE
document.getElementById("pause-btn").addEventListener("click", () => {
chargeAnim.pause();
label.textContent = "Paused";
});
// RESET
document.getElementById("reset-btn").addEventListener("click", () => {
chargeAnim.currentTime = 0;
chargeAnim.pause();
label.textContent = "Idle";
});
// SEEK TO 50%
document.getElementById("seek-half-btn").addEventListener("click", () => {
chargeAnim.currentTime = DURATION / 2;
label.textContent = "Seeked to 50%";
});

The currentTime property accepts a value in milliseconds measured from the start of the animation. Setting it directly jumps the animation to that point in the timeline without interpolation , which is useful for syncing a UI animation to an external game state.

Gameface extends the standard Web Animations API with a custom method: playFromTo(startTimeMs, pauseTimeMs) . When called, the animation seeks to startTimeMs and automatically pauses when it reaches pauseTimeMs. Both values are in milliseconds, counted from the beginning of the animation.

This is particularly useful for animations that represent multiple logical phases within a single timeline. Instead of splitting the animation into separate keyframe definitions, you define one continuous timeline and use playFromTo() to play specific segments on demand.

Using the same charging bar, the 4-second animation can be divided into logical phases:

play-from-to.js
const fill = document.getElementById("charge-fill");
const chargeAnim = fill.getAnimations()[0];
/* Phase 1: 0% to 50% fill (0ms to 2000ms in the 4s timeline) */
function playPhaseOne() {
chargeAnim.playFromTo(0, 2000);
}
/* Phase 2: 50% to 80% fill (2000ms to 3200ms) */
function playPhaseTwo() {
chargeAnim.playFromTo(2000, 3200);
}
/* Phase 3: 80% to 100% fill (3200ms to 4000ms) */
function playFinalPhase() {
chargeAnim.playFromTo(3200, 4000);
}

Each call plays only the specified segment and pauses at the endpoint. The game logic can decide which phase to trigger based on player actions, quest progress, or any other external state.