State.js is a lightweight CSS frontend framework that exposes DOM element states as CSS variables for data-driven animations and reactive UIs. Build dynamic, interactive interfaces using pure CSS and HTML.
State.js is a super simple, efficient and lightweight CSS framework that exposes DOM element states as CSS variables. Track data attributes, form inputs, media playback, and element visibility - all automatically exposed for use in your CSS animations and transitions.
A CSS-first approach to reactive interfaces.
Using nothing but CSS, HTML and State.js, you can create:
- 📊 Dynamic dashboards and data visualizations
- 🎯 Interactive web applications with writing only CSS
- 🎨 Data-driven animations in CSS
- 🎮 Complex UIs (including game interfaces, health bars, score systems)
State.js is really lightweight and created with vanilla JavaScript without requiring any dependencies. Perfect for CSS-first development and reactive UI patterns!
npm i @idevgames/state-js<script src="https://cdn.jsdelivr.net/npm/@idevgames/state-js/src/state.js"></script>Download state.js and include it in your project:
<script src="/js/state.js"></script>State.js automatically tracks when elements become visible:
<div class="fadeIn" data-state></div>.fadeIn {
opacity: 0;
}
.fadeIn.state {
animation: fadeIn 1s forwards ease-in-out;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}Watch data attributes and expose them as CSS variables. Here's an example using a health bar (perfect for games, but works for any progress indicator):
<div id="player"
data-state
data-state-watch="health,score"
data-state-var="true"
data-health="100"
data-health-min="0"
data-health-max="100"
data-score="0">
<div class="health-bar"></div>
</div>#player .health-bar {
width: var(--state-health-percent);
background: linear-gradient(90deg, red 0%, yellow 50%, green 100%);
}
/* Automatically triggered animations */
[data-health="0"] {
animation: death 2s forwards;
}
[data-health="10"],
[data-health="20"],
[data-health="30"] {
animation: pulse-red 1s infinite;
}Update the state by simply changing the data attribute:
// Change health (State.js watches and updates CSS vars automatically)
document.getElementById('player').setAttribute('data-health', '75');No JavaScript needed! Automatically bind form inputs to update other elements:
<!-- Input automatically updates the healthBar element -->
<input type="range"
id="healthSlider"
data-state
data-state-bind="healthBar"
data-state-attr="health"
min="0"
max="100"
value="75">
<!-- This element auto-updates when slider changes -->
<div id="healthBar"
data-state
data-state-watch="health"
data-health="75">
<div class="bar" style="width: var(--state-health-percent)"></div>
<span data-state-display="health">75</span>
</div>Bind to multiple elements (comma-separated):
<input data-state-bind="player,enemyHealthBar,scoreDisplay" data-state-attr="health">Make any element clickable to control state:
<!-- Player with power-up state -->
<div id="player"
data-state
data-state-toggles="powered"
data-powered="false">
Player Character
</div>
<!-- Button that toggles the power-up on/off -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-toggle="powered">
Toggle Power-Up
</button>
<!-- Button that sets health to a specific value -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="health"
data-state-value="100">
Full Health
</button>
<!-- Button that increments score by 10 (perfect for clickers!) -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="score"
data-state-increment="10">
Add 10 Points
</button>Trigger Modes:
- Toggle:
data-state-toggle="attribute"- Flips between true/false - Set:
data-state-attr="attribute"+data-state-value="value"- Sets specific value - Increment:
data-state-attr="attribute"+data-state-increment="amount"- Adds to current value - Decrement:
data-state-attr="attribute"+data-state-decrement="amount"- Subtracts from current value
Advanced: Dynamic Calculations
Both increment and decrement support calc() expressions with CSS variables:
<!-- Static increment -->
<button data-state-increment="10">Add 10</button>
<!-- Dynamic: increment scales with level -->
<button data-state-increment="calc(var(--state-level) * 5)">
Level-scaled Click
</button>
<!-- Dynamic: cost increases with score -->
<button data-state-increment="calc(1 + var(--state-score) * 0.1)">
Increasing Returns
</button>Both increment and decrement automatically respect data-[attr]-min and data-[attr]-max bounds!
Conditional Triggers:
Use data-state-condition to only execute operations when a condition is met (perfect for costs, requirements, unlock systems):
<!-- Only works if score >= 20 -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="level"
data-state-increment="1"
data-state-condition="score >= 20">
Level Up (costs 20)
</button>
<!-- Complex conditions with AND/OR -->
<button data-state-condition="gold >= 100 and level < 10">
Affordable Upgrade
</button>
<!-- Multiple attributes -->
<button data-state-condition="health > 0 and mana >= 50">
Cast Spell
</button>When a condition fails, the button gets the state-disabled class automatically! Style it with CSS:
.state-disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}Chaining Multiple Operations:
Use data-state-trigger-chain to perform multiple operations sequentially (perfect for complex game mechanics):
<!-- Level up button that both spends gold AND increases level -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-condition="gold >= 100"
data-state-trigger-chain="spendGold,gainLevel">
Level Up (costs 100 gold)
</button>
<!-- Hidden trigger: deduct gold -->
<button id="spendGold"
data-state
data-state-trigger
data-state-bind="player"
data-state-attr="gold"
data-state-decrement="100"
style="display:none">
</button>
<!-- Hidden trigger: add level -->
<button id="gainLevel"
data-state
data-state-trigger
data-state-bind="player"
data-state-attr="level"
data-state-increment="1"
style="display:none">
</button>Auto-firing Triggers:
Use data-state-autofire="true" to automatically fire a trigger whenever its condition becomes true (perfect for passive income, auto-unlocks, achievements, and automatic progression):
<!-- Passive income: auto-collect gold whenever it reaches 10 -->
<button id="autoCollect"
data-state
data-state-trigger
data-state-bind="player"
data-state-attr="gold"
data-state-decrement="10"
data-state-condition="gold >= 10"
data-state-autofire="true"
data-state-trigger-chain="addScore"
style="display:none">
</button>
<button id="addScore"
data-state
data-state-trigger
data-state-bind="player"
data-state-attr="score"
data-state-increment="10"
style="display:none">
</button>
<!-- Auto-unlock: automatically upgrade when level reaches 5 -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="upgraded"
data-state-set="true"
data-state-condition="level >= 5"
data-state-autofire="true"
style="display:none">
</button>
<!-- Achievement system: auto-trigger when condition met -->
<button data-state
data-state-trigger
data-state-bind="achievements"
data-state-attr="firstWin"
data-state-set="true"
data-state-condition="wins >= 1"
data-state-autofire="true"
style="display:none">
</button>The magic: When the condition transitions from false → true, the trigger fires automatically! No click required. No visibility required. This is the missing primitive for automatic game mechanics.
Works with any element:
<div data-state-trigger data-state-bind="player" data-state-toggle="shielded">
Click me to toggle shield!
</div>State.js v1.1.0 adds seven powerful declarative primitives specifically designed for game development and interactive experiences. Build complete games with zero hand-written JavaScript logic.
Automatically fire triggers at regular intervals (perfect for passive income, cooldowns, game ticks):
<!-- Passive gold income: +1 gold every second -->
<button id="passiveGold"
data-state
data-state-trigger
data-state-bind="player"
data-state-attr="gold"
data-state-increment="1"
data-state-interval="1000"
style="display:none">
</button>
<!-- Health regeneration: +5 HP every 2 seconds (only if alive) -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="health"
data-state-increment="5"
data-state-interval="2000"
data-state-condition="health > 0 and health < 100"
style="display:none">
</button>How it works:
- Fires the trigger automatically every N milliseconds
- Respects
data-state-condition(won't fire if condition is false) - Uses a single efficient shared scheduler for all interval triggers
- Perfect for idle games, passive effects, and time-based mechanics
Set an attribute to an exact value (unlike increment/decrement). Supports calc() expressions:
<!-- Reset health to full -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="health"
data-state-set="100">
Full Heal
</button>
<!-- Set mana to half of max -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="mana"
data-state-set="calc(var(--state-manamax) / 2)">
Restore 50% Mana
</button>
<!-- Level-scaled restore -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="gold"
data-state-set="calc(var(--state-level) * 100)">
Set Gold to Level × 100
</button>Use cases:
- Reset/restore mechanics
- Level-scaled rewards
- Percentage-based calculations
- Achievement unlocks (set boolean flags)
Display dynamic text using {token} syntax that updates automatically:
<div id="player"
data-state
data-state-watch="level,health,healthmax,gold"
data-level="1"
data-health="100"
data-healthmax="100"
data-gold="0">
</div>
<!-- Text updates automatically when attributes change -->
<h1 data-state
data-state-bind="player"
data-state-text="Level {level} Hero">
</h1>
<p data-state
data-state-bind="player"
data-state-text="HP: {health}/{healthmax}">
</p>
<div data-state
data-state-bind="player"
data-state-text="Gold: {gold} | Level: {level}">
</div>
<!-- Works with any attribute -->
<span data-state
data-state-bind="player"
data-state-text="You have {gold} gold coins!">
</span>How it works:
- Replaces
{attributeName}tokens with current attribute values - Updates automatically when any referenced attribute changes
- Supports multiple tokens in one template
- No manual display element management required
Dynamically add/remove CSS classes based on conditions:
<!-- Add 'critical' class when health is low -->
<div id="healthBar"
data-state
data-state-bind="player"
data-state-class="critical"
data-state-class-condition="health <= 20">
</div>
<!-- Multiple conditional classes using numbered suffixes -->
<div id="player"
data-state
data-state-bind="game"
data-state-class="low-health"
data-state-class-condition="health <= 30"
data-state-class-2="powered-up"
data-state-class-condition-2="powerup == true"
data-state-class-3="max-level"
data-state-class-condition-3="level >= 99">
</div>
<!-- Style the classes in CSS -->
<style>
.critical {
animation: critical-pulse 0.5s infinite;
border: 3px solid red;
}
.low-health {
filter: hue-rotate(180deg);
}
.powered-up {
box-shadow: 0 0 20px gold;
animation: glow 1s infinite;
}
.max-level {
background: linear-gradient(45deg, gold, orange);
}
</style>Features:
- Supports up to 10 class/condition pairs per element (use
-2,-3, etc.) - Classes add/remove automatically when conditions change
- Perfect for visual state feedback
- Works with any CSS animations or effects
Play procedurally generated Web Audio sounds on trigger clicks (no audio files needed!):
<!-- Built-in sounds: click, levelup, buy, error, coin -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="score"
data-state-increment="1"
data-state-sound="click">
Click (+1 score)
</button>
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="level"
data-state-increment="1"
data-state-sound="levelup"
data-state-condition="xp >= 100">
Level Up!
</button>
<button data-state
data-state-trigger
data-state-bind="shop"
data-state-attr="gold"
data-state-decrement="50"
data-state-sound="buy"
data-state-condition="gold >= 50">
Buy Item (50g)
</button>
<!-- Error sound when clicking disabled buttons -->
<button data-state
data-state-trigger
data-state-sound="error"
data-state-condition="gold >= 1000">
Expensive Item (1000g)
</button>
<!-- Coin pickup sound -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="gold"
data-state-increment="10"
data-state-sound="coin">
Collect Gold
</button>Built-in sounds:
- click - 80ms sawtooth beep (UI feedback)
- levelup - 3-note arpeggio C4→E4→G4 (achievements)
- buy - 100ms sine tone at 600Hz (purchases)
- error - 80ms square wave at 120Hz (failures)
- coin - Rising pitch 880→1200Hz (pickups)
Features:
- Zero external dependencies (uses Web Audio API)
- Procedurally generated (no audio files to load)
- Plays on trigger click before executing the action
- Respects browser autoplay policies
Automatically save and restore state to localStorage:
<div id="gameState"
data-state
data-state-watch="level,gold,health,xp"
data-state-persist="true"
data-state-persist-key="my-game-save"
data-level="1"
data-gold="0"
data-health="100"
data-xp="0">
</div>How it works:
- Automatically loads saved state on page load
- Saves changes to localStorage with 500ms debounce (prevents excessive writes)
- Saves all attributes listed in
data-state-watch - Uses element ID as save key if
data-state-persist-keynot specified - Perfect for idle games, progress persistence, user preferences
Clear saved data:
// From browser console or your own JS:
localStorage.removeItem('my-game-save');Dispatch CustomEvents when triggers fire (perfect for external integrations, analytics, achievements):
<!-- Dispatch event when score increases -->
<button data-state
data-state-trigger
data-state-bind="player"
data-state-attr="score"
data-state-increment="10"
data-state-event="score-increased">
+10 Score
</button>
<!-- Listen to events in JavaScript -->
<script>
document.addEventListener('state:score-increased', (e) => {
console.log('Score changed!', e.detail);
// e.detail contains:
// {
// element: <the trigger button>,
// attr: "score",
// oldValue: "0",
// newValue: "10",
// boundId: "player"
// }
});
// Track level-ups
document.addEventListener('state:level-up', (e) => {
// Send to analytics
gtag('event', 'level_up', { level: e.detail.newValue });
});
// Achievement tracking
document.addEventListener('state:achievement-unlocked', (e) => {
showNotification(`Achievement unlocked: ${e.detail.attr}!`);
});
</script>Use cases:
- Analytics integration
- Achievement systems
- External UI updates
- Debug logging
- Third-party integrations
Event naming:
- Event name is prefixed with
state:(e.g.,data-state-event="win"→state:win) - Events bubble up the DOM
- Not cancelable (fire-and-forget)
Combining all extensions, here's a complete idle clicker game:
<div id="game"
data-state
data-state-watch="gold,goldPerClick,goldPerSecond,level"
data-state-persist="true"
data-state-persist-key="idle-game-v1"
data-gold="0"
data-goldPerClick="1"
data-goldPerSecond="0"
data-level="1">
<!-- Display with template interpolation -->
<h1 data-state
data-state-bind="game"
data-state-text="Level {level} Miner">
</h1>
<p data-state
data-state-bind="game"
data-state-text="Gold: {gold} | Per Click: {goldPerClick} | Per Second: {goldPerSecond}">
</p>
<!-- Manual clicking -->
<button data-state
data-state-trigger
data-state-bind="game"
data-state-attr="gold"
data-state-increment="calc(var(--state-goldPerClick))"
data-state-sound="coin"
data-state-event="gold-mined">
Mine Gold
</button>
<!-- Upgrades with conditional classes -->
<button id="upgradeClick"
data-state
data-state-trigger
data-state-bind="game"
data-state-trigger-chain="payUpgrade,addPower"
data-state-condition="gold >= 50"
data-state-sound="buy"
data-state-class="affordable"
data-state-class-condition="gold >= 50">
Upgrade Pickaxe (50g)
</button>
<!-- Hidden triggers for upgrade chain -->
<button id="payUpgrade"
data-state-trigger
data-state-bind="game"
data-state-attr="gold"
data-state-decrement="50"
style="display:none">
</button>
<button id="addPower"
data-state-trigger
data-state-bind="game"
data-state-attr="goldPerClick"
data-state-increment="1"
style="display:none">
</button>
<!-- Passive income with intervals -->
<button data-state
data-state-trigger
data-state-bind="game"
data-state-attr="gold"
data-state-increment="calc(var(--state-goldPerSecond))"
data-state-interval="1000"
data-state-condition="goldPerSecond > 0"
style="display:none">
</button>
<!-- Auto-level-up when gold reaches threshold -->
<button data-state
data-state-trigger
data-state-bind="game"
data-state-attr="level"
data-state-increment="1"
data-state-condition="gold >= 500"
data-state-autofire="true"
data-state-sound="levelup"
data-state-event="level-up"
style="display:none">
</button>
</div>
<style>
/* Visual feedback with conditional classes */
.affordable {
background: gold;
animation: pulse 0.5s infinite;
}
#game[data-level="10"],
#game[data-level="25"],
#game[data-level="50"] {
animation: milestone-celebration 1s ease-out;
}
</style>This game has:
- ✅ Manual clicking with dynamic rewards
- ✅ Upgrade system with costs
- ✅ Passive income ticking every second
- ✅ Auto-level-up when reaching milestones
- ✅ Sound effects for all actions
- ✅ Visual feedback for affordability
- ✅ Persistent save/load with localStorage
- ✅ Event dispatch for analytics/achievements
- ✅ ZERO hand-written game logic JavaScript!
State.js automatically creates CSS variables based on your configuration:
--state-visible(0 or 1)--state-intersection(0-100%)--state-viewport-x(0-100%)--state-viewport-y(0-100%)
When using data-state-watch="health,score,level":
--state-health(raw value)--state-health-percent(0-100%)--state-health-normalized(0-1)--state-health-deg(0-360deg)--state-health-reverse(100%-0%)--state-score(raw value)--state-level(raw value)
--state-value(current value)--state-value-percent(percentage of range)--state-min,--state-max(range bounds)
--state-time(current time)--state-progress(0-100%)--state-playing(0 or 1)--state-volume(0-100)
--state-width(px)--state-height(px)--state-aspect-ratio(calculated)
<div data-state></div>
<!-- OR -->
<div class="enable-state"></div>| Attribute | Description | Example |
|---|---|---|
data-state-var="true" |
Enable all CSS variables | data-state-var="true" |
data-state-watch="attr1,attr2" |
Watch specific data attributes | data-state-watch="health,mana,xp" |
data-state-bind="id1,id2" |
Auto-bind input to element IDs | data-state-bind="player,enemy" |
data-state-attr="attrName" |
Which attribute to update when binding | data-state-attr="health" |
data-state-value="value" |
Value to set when trigger is clicked (supports calc()) | data-state-value="100" or calc(var(--state-level) * 10) |
data-state-increment="amount" |
Amount to add when trigger is clicked (supports calc(), respects min/max) | data-state-increment="10" or calc(var(--state-level) * 5) |
data-state-decrement="amount" |
Amount to subtract when trigger is clicked (supports calc(), respects min/max) | data-state-decrement="5" or calc(var(--state-cost)) |
data-state-trigger |
Make element clickable to trigger state changes | data-state-trigger |
data-state-trigger-chain="id1,id2" |
Click other triggers sequentially after this one | data-state-trigger-chain="payCost,addLevel" |
data-state-condition="expression" |
Only execute if condition is true (adds state-disabled class when false) |
data-state-condition="score >= 20" or "gold >= 100 and level < 10" |
data-state-autofire="true" |
Automatically fire trigger when condition becomes true (requires data-state-condition) |
data-state-autofire="true" |
data-state-toggle="attrName" |
Toggle boolean attribute on/off when clicked | data-state-toggle="powered" |
data-state-display="attrName" |
Auto-display attribute value as text | data-state-display="health" |
| NEW v1.1.0 | Game Development Extensions | |
data-state-interval="ms" |
Auto-fire trigger every N milliseconds (respects conditions) | data-state-interval="1000" |
data-state-set="value" |
Set attribute to exact value (supports calc()) | data-state-set="100" or calc(var(--state-max)) |
data-state-text="template" |
Template string with {token} interpolation | data-state-text="HP {health}/{healthmax}" |
data-state-class="className" |
Conditional CSS class application | data-state-class="critical" |
data-state-class-condition="expr" |
Condition for class (use with data-state-class) | data-state-class-condition="health <= 20" |
data-state-sound="soundName" |
Play Web Audio sound on trigger (click, levelup, buy, error, coin) | data-state-sound="coin" |
data-state-persist="true" |
Auto-save/restore to localStorage | data-state-persist="true" |
data-state-persist-key="key" |
localStorage key (optional, defaults to element ID) | data-state-persist-key="my-game" |
data-state-event="eventName" |
Dispatch CustomEvent as "state:eventName" | data-state-event="score-up" |
| NEW v1.2.0 | HTML Includes | |
data-state-include="path" |
Fetch and inject HTML component from URL | data-state-include="components/card.html" |
data-state-toggles="attr1,attr2" |
Boolean state toggles | data-state-toggles="active,locked" |
data-state-dimensions="true" |
Track width/height | data-state-dimensions="true" |
data-state-media="true" |
Track media playback | data-state-media="true" |
data-state-global="true" |
Set CSS vars on :root |
data-state-global="true" |
data-state-increment="10" |
Update increment for selectors | data-state-increment="10" |
| NEW v1.4.0 | Event-Based Triggers | |
data-state-trigger-on="eventName" |
Fire trigger on DOM event (default: "click") | data-state-trigger-on="mouseenter" or "input" or "focus" |
data-state-debounce="ms" |
Delay trigger execution until events stop (in milliseconds) | data-state-debounce="500" |
data-state-throttle="ms" |
Limit trigger firing rate (max once per N ms) | data-state-throttle="200" |
<div data-state
data-state-watch="health"
data-health="100"
data-health-min="0"
data-health-max="100">
</div>Build reusable, modular HTML components - just like any modern framework, but with zero build tools.
HTML Includes let you fetch and inject components declaratively. Create a component once, use it everywhere. Perfect for health bars, UI cards, player stats, inventory items, or any repeating UI pattern.
<!-- From external file (cached after first load) -->
<div data-state-include="components/health-bar.html"></div>
<!-- From inline template (instant, zero latency) -->
<div data-state-include="#health-bar-template"></div>
<!-- Override component attributes -->
<div data-state-include="components/health-bar.html"
id="player-health"
data-hp="75"
data-hp-max="150"></div>
<!-- Element tag doesn't matter, gets replaced -->
<i data-state-include="components/icon.html"></i>Option 1: External File (for modularity)
components/health-bar.html:
<div class="health-bar"
data-state
data-state-watch="hp"
data-state-var="true"
data-hp="100"
data-hp-max="100">
<div class="fill" style="width: var(--state-hp-percent); background: green; height: 20px;"></div>
<span data-state-display="hp"></span>
</div>Option 2: Inline Template (for performance)
<!-- Define template once in your HTML -->
<template id="health-bar-template">
<div class="health-bar"
data-state
data-state-watch="hp"
data-state-var="true"
data-hp="100"
data-hp-max="100">
<div class="fill" style="width: var(--state-hp-percent); background: green; height: 20px;"></div>
<span data-state-display="hp"></span>
</div>
</template>
<!-- Use it anywhere (instant, no network request) -->
<div data-state-include="#health-bar-template" data-hp="75"></div>
<div data-state-include="#health-bar-template" data-hp="50"></div>
<div data-state-include="#health-bar-template" data-hp="100"></div>Template Mode (#id):
- Clones from
<template>tag or element by ID (instant, zero latency) - Merges attributes from include element to cloned component
- Replaces include element with component
- Initializes State.js on the injected component
Fetch Mode (path.html):
- Fetches HTML from URL (cached after first load)
- Merges attributes from include element to fetched component
- Replaces include element with component
- Initializes State.js on the injected component
All State.js features (triggers, persistence, intervals, sounds, etc.) work perfectly in included components!
- Health/Mana Bars - Define once, use for player, enemies, NPCs
- Inventory Items - Consistent item cards across inventory, shop, tooltip
- UI Cards - Stat displays, achievement cards, notifications
- Player Stats - Level, XP, gold displays
- Navigation - Shared headers, footers, menus across pages
| Attribute | Description | Example |
|---|---|---|
data-state-include="path.html" |
Fetch and inject HTML from URL | data-state-include="components/card.html" |
data-state-include="#id" |
Clone from template or element by ID | data-state-include="#card-template" |
Note: Any other attributes on the include element are copied to the injected component, allowing you to override default values.
Use templates (#id) for:
- Critical, frequently-used components (zero latency)
- Components needed immediately on page load
- Single-page apps where all components fit in initial HTML
Use files (path.html) for:
- Large component libraries (keeps HTML small)
- Components used across multiple pages (modularity)
- Production apps with proper HTTP caching
Local Development: File-based includes require HTTP/HTTPS (browser security prevents file:// fetching). Run any simple local server - Python's python -m http.server, Node's npx http-server, or VS Code Live Server. Template-based includes work anywhere, including file://!
Automatically calculate derived values from your data attributes - no manual updates needed!
Computed state keeps calculated values in sync with their dependencies. Perfect for health percentages, damage calculations, level-up requirements, or any derived game logic.
<div id="player"
data-state
data-state-watch="hp,maxHp"
data-state-compute="hpPercent = hp / maxHp * 100"
data-hp="75"
data-maxHp="100">
<div class="health-bar" style="width: var(--state-hpPercent)%;"></div>
<span>HP: <span data-state-display="hpPercent"></span>%</span>
</div>Use semicolons to define multiple computed values:
<div data-state
data-state-watch="hp,maxHp,level"
data-state-compute="
hpPercent = hp / maxHp * 100;
isCritical = hp < 20;
nextLevelXp = level * 100
"
data-hp="15"
data-maxHp="100"
data-level="5">
</div>- Math operators:
+,-,*,/,%,() - Comparisons:
<,>,<=,>=,==,!= - Logical operators:
&&,||,! - Ternary:
condition ? valueA : valueB - Attribute references: Use attribute names directly (e.g.,
hp,maxHp)
<!-- Percentage calculation -->
<div data-state-compute="progress = completed / total * 100">
<!-- Ternary operator -->
<div data-state-compute="status = hp > 0 ? 'alive' : 'dead'">
<!-- Complex formula -->
<div data-state-compute="damage = (attack * 2) - defense">
<!-- Boolean check -->
<div data-state-compute="canLevelUp = xp >= level * 100">
<!-- Multiple dependencies -->
<div data-state-compute="totalStats = strength + agility + intelligence">
</div>- Parse - State.js parses your compute expressions on setup
- Auto-update - When any dependency changes, computed values recalculate automatically
- Expose - Computed values become
data-${name}attributes and--state-${name}CSS variables - Display - Use
data-state-displayto show computed values in your UI
- Health/Mana percentages for progress bars
- Damage calculations for combat systems
- Level-up requirements (XP needed, stats gained)
- Resource management (inventory space, currency conversions)
- Status checks (isCritical, canAfford, isComplete)
- Score calculations (combos, multipliers, totals)
Console tools for inspecting and debugging reactive state - perfect for development and testing.
State.js provides a JavaScript API accessible via the browser console for debugging your application state.
Returns an array of all reactive elements with their current state:
// In browser console
State.inspectAll()
/* Returns:
[
{
element: <div id="player">,
id: "player",
state: { hp: "75", maxHp: "100", hpPercent: "75" },
config: { ... }
},
...
]
*/Inspect a specific element's state:
State.inspect('#player')
/* Returns:
{
element: <div id="player">,
id: "player",
state: { hp: "75", maxHp: "100", hpPercent: "75" },
config: { watchAttrs: ["hp", "maxHp"], ... }
}
*/Enable/disable console logging for attribute changes:
// Enable tracing for HP changes
State.trace('hp', true)
// Now every time data-hp changes, you'll see:
// State.js [hp]: { element: <div>, id: "player", attribute: "hp", oldValue: "75", newValue: "65" }
// Disable tracing
State.trace('hp', false)// Debug all reactive elements
const elements = State.inspectAll()
console.table(elements.map(e => e.state))
// Check specific element state
const player = State.inspect('#player')
console.log('Player HP:', player.state.hp)
// Trace multiple attributes
State.trace('hp')
State.trace('gold')
State.trace('xp')
// Now see all changes to hp, gold, and xp in real-time
// Find elements with low HP
State.inspectAll()
.filter(e => parseFloat(e.state.hp) < 20)
.forEach(e => console.log(`${e.id} is critical!`))- Debugging - See all state changes in real-time
- Testing - Verify attribute values during development
- Optimization - Track which attributes update frequently
- Learning - Understand how State.js works internally
Fire triggers on ANY DOM event - not just clicks! Listen to hover, focus, input, scroll, and more with built-in debounce/throttle support.
Event-based triggers let you respond to any DOM event declaratively. Perfect for form interactions, hover effects, scroll tracking, visibility detection, and real-time input validation - all without writing JavaScript event listeners.
Use data-state-trigger-on to specify which event should fire the trigger:
<!-- Default: click (backward compatible) -->
<button data-state-trigger
data-state-bind="player"
data-state-attr="score"
data-state-increment="1">
Click to score
</button>
<!-- Explicit click -->
<button data-state-trigger
data-state-trigger-on="click"
data-state-bind="player"
data-state-attr="score"
data-state-increment="1">
Click to score
</button>
<!-- Hover to increment -->
<div data-state-trigger
data-state-trigger-on="mouseenter"
data-state-bind="stats"
data-state-attr="hovers"
data-state-increment="1">
Hover over me!
</div>
<!-- Fire on focus -->
<input data-state-trigger
data-state-trigger-on="focus"
data-state-bind="form"
data-state-attr="activeField"
data-state-set="username">
<!-- Fire on form submission -->
<form data-state-trigger
data-state-trigger-on="submit"
data-state-bind="stats"
data-state-attr="submits"
data-state-increment="1">
<!-- Form automatically prevents page reload -->
<input type="text" name="username">
<button type="submit">Submit</button>
</form>Mouse Events:
click- Default trigger behaviordblclick- Double-clickmouseenter- Mouse enters elementmouseleave- Mouse leaves elementmouseover- Mouse moves over elementmouseout- Mouse moves out of element
Form Events:
input- Text input, range slider changes (fires on every keystroke)change- Select dropdowns, checkboxes, radio buttons (fires on blur/commit)focus- Element gains focusblur- Element loses focussubmit- Form submission (automatically callspreventDefault())
Keyboard Events:
keydown- Key is pressed downkeyup- Key is releasedkeypress- Key is pressed (deprecated but supported)
Scroll Events:
scroll- Element scrolls (use with throttle!)
Custom Events:
intersect- Element becomes visible (custom event from IntersectionObserver)- Any CustomEvent dispatched via JavaScript
Use data-state-debounce to delay execution until events stop firing:
<!-- Search input: only fire 300ms after user stops typing -->
<input type="text"
data-state-trigger
data-state-trigger-on="input"
data-state-debounce="300"
data-state-bind="search"
data-state-attr="query"
data-state-increment="1">
<!-- Resize handler: only fire 500ms after window stops resizing -->
<div data-state-trigger
data-state-trigger-on="resize"
data-state-debounce="500"
data-state-bind="layout"
data-state-attr="width"
data-state-set="calc(100)">
</div>How debounce works:
- Event fires (e.g., user types a character)
- Timer starts counting down from specified ms
- If another event fires before timer completes, reset the timer
- When timer completes without interruption, execute trigger
- Use for: Text input, resize, autocomplete, validation
Use data-state-throttle to limit how often a trigger can fire:
<!-- Scroll tracking: fire at most once per 200ms (5x/second max) -->
<div data-state-trigger
data-state-trigger-on="scroll"
data-state-throttle="200"
data-state-bind="stats"
data-state-attr="scrolls"
data-state-increment="1"
style="height: 200px; overflow-y: scroll;">
<div style="height: 1000px;">Scroll content...</div>
</div>
<!-- Mouse tracking: limit to 100ms (10x/second max) -->
<div data-state-trigger
data-state-trigger-on="mousemove"
data-state-throttle="100"
data-state-bind="cursor"
data-state-attr="moves"
data-state-increment="1">
Track mouse movement
</div>How throttle works:
- Event fires and trigger executes immediately
- Start cooldown timer for specified ms
- Any events during cooldown are ignored
- After cooldown completes, next event can fire
- Use for: Scroll, mousemove, resize, frequent events
Debounce vs Throttle:
- Debounce: Wait until activity stops → fires once at the end
- Throttle: Fire regularly during activity → fires multiple times at limited rate
The intersect event fires when an element enters the viewport:
<!-- Track when user scrolls element into view -->
<div data-state-trigger
data-state-trigger-on="intersect"
data-state-bind="analytics"
data-state-attr="views"
data-state-increment="1">
Content that tracks visibility
</div>
<!-- Lazy-load content -->
<div data-state-trigger
data-state-trigger-on="intersect"
data-state-bind="lazySection"
data-state-attr="loaded"
data-state-set="true">
<!-- Fires once when scrolled into view -->
</div>How it works:
- State.js uses IntersectionObserver to track visibility
- When element becomes visible for the first time, dispatches
intersectCustomEvent - Trigger fires and can update state, trigger chains, play sounds, etc.
- Use for: Analytics, lazy loading, scroll-triggered animations, achievement tracking
Form submit events automatically call preventDefault() to prevent page reload:
<form id="contactForm"
data-state
data-state-watch="submits"
data-submits="0">
<input type="text" name="email" required>
<!-- Submit increments counter WITHOUT reloading page -->
<button type="submit"
data-state-trigger
data-state-trigger-on="submit"
data-state-bind="contactForm"
data-state-attr="submits"
data-state-increment="1"
data-state-sound="buy"
data-state-event="form-submitted">
Submit
</button>
</form>
<p>Submissions: <span data-state-display="submits">0</span></p>No JavaScript required! The form won't reload the page - State.js handles it automatically.
Event triggers respect data-state-condition just like click triggers:
<div id="game"
data-state
data-state-watch="gold,active"
data-state-toggles="active"
data-gold="0"
data-active="false">
<!-- Only track hovers when game is active -->
<div data-state-trigger
data-state-trigger-on="mouseenter"
data-state-condition="active == true"
data-state-bind="game"
data-state-attr="gold"
data-state-increment="1">
Hover to collect gold (only when active)
</div>
<!-- Only track input when game is active -->
<input data-state-trigger
data-state-trigger-on="input"
data-state-debounce="500"
data-state-condition="active == true"
data-state-bind="game"
data-state-attr="gold"
data-state-increment="5">
</div>Result: Triggers are disabled (get state-disabled class) when condition is false, just like click triggers!
<div id="search"
data-state
data-state-watch="queries"
data-queries="0">
<!-- Debounced search: only counts after user stops typing -->
<input type="text"
placeholder="Search..."
data-state-trigger
data-state-trigger-on="input"
data-state-debounce="500"
data-state-bind="search"
data-state-attr="queries"
data-state-increment="1"
data-state-event="search-query">
<p>Searches performed: <span data-state-display="queries">0</span></p>
</div><div id="article"
data-state
data-state-watch="scrollEvents"
data-scrollEvents="0">
<div class="content"
data-state-trigger
data-state-trigger-on="scroll"
data-state-throttle="200"
data-state-bind="article"
data-state-attr="scrollEvents"
data-state-increment="1"
style="height: 300px; overflow-y: scroll;">
<div style="height: 2000px;">Long scrollable content...</div>
</div>
<p>Scroll events: <span data-state-display="scrollEvents">0</span></p>
</div><div id="formTracking"
data-state
data-state-watch="focusCount,changes"
data-focusCount="0"
data-changes="0">
<!-- Track focus -->
<input type="text"
placeholder="Username"
data-state-trigger
data-state-trigger-on="focus"
data-state-bind="formTracking"
data-state-attr="focusCount"
data-state-increment="1">
<!-- Track changes -->
<select data-state-trigger
data-state-trigger-on="change"
data-state-bind="formTracking"
data-state-attr="changes"
data-state-increment="1">
<option>Option 1</option>
<option>Option 2</option>
</select>
<p>Fields focused: <span data-state-display="focusCount">0</span></p>
<p>Changes made: <span data-state-display="changes">0</span></p>
</div>Event-based triggers are perfect for:
- 📝 Live search/autocomplete (input + debounce)
- 📊 Analytics tracking (focus, scroll, visibility)
- 🎯 Hover effects and interactions (mouseenter/leave)
- 📋 Form validation and submission (submit, change, blur)
- 📜 Scroll progress indicators (scroll + throttle)
- 👀 Lazy loading and content reveal (intersect)
- ⌨️ Keyboard shortcut tracking (keydown/up)
- 🎮 Interactive games (mousemove, keypress)
- 📱 Mobile gesture tracking (with Touch.js integration)
- Always throttle scroll and mousemove events (100-200ms recommended)
- Debounce text input for search/autocomplete (300-500ms recommended)
- Use intersect for lazy loading instead of scroll events
- Combine with conditions to disable triggers when not needed
- Prefer change over input for dropdowns/checkboxes (fires less frequently)
| Attribute | Description | Example |
|---|---|---|
data-state-trigger-on="eventName" |
Which DOM event fires the trigger (default: "click") | data-state-trigger-on="mouseenter" |
data-state-debounce="ms" |
Delay trigger execution until events stop (in milliseconds) | data-state-debounce="500" |
data-state-throttle="ms" |
Limit trigger firing rate (max once per N milliseconds) | data-state-throttle="200" |
Special behaviors:
submitevents automatically callevent.preventDefault()intersectis a custom event fired by IntersectionObserverclickis the default ifdata-state-trigger-onis omitted- Triggers with
trigger-on="click"still getcursor: pointerstyle
State.js includes state-animations.css - a companion stylesheet with predefined animations for common UI patterns and interactive elements.
<link rel="stylesheet" href="src/state-animations.css">.state-notification- Notification slide.state-warning- Warning shake.state-success- Success bounce.state-error- Error shake.state-loading- Loading spin
.state-health-low- Low value warning pulse.state-health-critical- Critical state shake[data-health="0"]- Empty state animation[data-health="100"]- Full/complete glow
.state-score-increase- Value increase pop.state-score-milestone- Milestone celebration.state-level-up- Level/tier change flash
.state-powered- Active/powered state glow.state-invincible- Protected state shimmer.state-shielded- Shield/protection pulse.state-stunned- Disabled/paused effect.state-poisoned- Negative effect pulse.state-frozen- Frozen/locked shake.state-burning- Active damage flicker.state-healing- Positive effect sparkle
View full animation documentation →
<div id="player"
data-state
data-state-watch="health,mana,xp,level"
data-state-var="true"
data-health="100"
data-mana="80"
data-xp="450"
data-level="5"
data-health-max="100"
data-mana-max="100"
data-xp-max="1000">
<div class="health-bar" style="width: var(--state-health-percent)"></div>
<div class="mana-bar" style="width: var(--state-mana-percent)"></div>
<div class="xp-bar" style="width: var(--state-xp-percent)"></div>
<div class="level">Level <span style="--content: var(--state-level)"></span></div>
</div><video data-state
data-state-media="true"
data-state-var="true">
<source src="video.mp4">
</video>
<style>
video::after {
content: "";
width: var(--state-progress);
height: 5px;
background: red;
position: absolute;
bottom: 0;
left: 0;
}
</style><div data-state
data-state-toggles="active,locked,complete"
data-active="true"
data-locked="false"
data-complete="false">
</div>/* Automatically applied classes */
.state-active {
filter: brightness(1.2);
transform: scale(1.05);
}
.state-locked {
filter: grayscale(1) brightness(0.6);
cursor: not-allowed;
}
.state-complete {
animation: complete-check 0.5s forwards;
}<div id="clicker"
data-state
data-state-watch="score"
data-state-var="true"
data-score="0"
data-score-max="100">
<h1>Score: <span data-state-display="score">0</span></h1>
<button data-state
data-state-trigger
data-state-bind="clicker"
data-state-attr="score"
data-state-increment="1">
Click Me!
</button>
</div>
<style>
/* Celebrate milestones with CSS alone */
#clicker[data-score="10"],
#clicker[data-score="20"],
#clicker[data-score="30"] {
animation: milestone-burst 0.5s ease-out;
}
#clicker[data-score="100"] {
animation: victory-flash 1s ease-out;
}
/* Progress bar using CSS variables */
#clicker::after {
content: "";
width: var(--state-score-percent);
height: 10px;
background: linear-gradient(90deg, red, yellow, green);
}
</style><div id="audio"
data-state
data-state-watch="volume"
data-state-var="true"
data-volume="50"
data-volume-min="0"
data-volume-max="100">
<h2>Volume: <span data-state-display="volume">50</span>%</h2>
<!-- Decrement button (auto-stops at 0) -->
<button data-state
data-state-trigger
data-state-bind="audio"
data-state-attr="volume"
data-state-decrement="10">
-
</button>
<!-- Increment button (auto-stops at 100) -->
<button data-state
data-state-trigger
data-state-bind="audio"
data-state-attr="volume"
data-state-increment="10">
+
</button>
<!-- Visual bar updates automatically -->
<div class="volume-bar" style="width: var(--state-volume-percent);"></div>
</div><div id="idleGame"
data-state
data-state-watch="gold,level,clickPower"
data-state-var="true"
data-gold="0"
data-level="1"
data-clickPower="1">
<h1>Gold: <span data-state-display="gold">0</span></h1>
<h2>Level: <span data-state-display="level">1</span></h2>
<p>Click Power: <span data-state-display="clickPower">1</span></p>
<!-- Basic click: adds clickPower to gold -->
<button data-state
data-state-trigger
data-state-bind="idleGame"
data-state-attr="gold"
data-state-increment="calc(var(--state-clickPower))">
Mine Gold
</button>
<!-- Upgrade: increases clickPower, costs gold -->
<button data-state
data-state-trigger
data-state-bind="idleGame"
data-state-attr="clickPower"
data-state-increment="1">
Upgrade Pick (+1 power)
</button>
<!-- Level up: costs increase with level -->
<button data-state
data-state-trigger
data-state-bind="idleGame"
data-state-attr="level"
data-state-increment="1">
Level Up
</button>
</div>
<style>
/* Different animations per level */
#idleGame[data-level="5"],
#idleGame[data-level="10"] {
animation: level-milestone 1s ease-out;
}
/* Click power visualization */
#idleGame::after {
content: "";
width: calc(var(--state-clickPower) * 10px);
height: 5px;
background: gold;
}
</style>State.js is part of a complete CSS/HTML UI development toolkit from iDev Games:
Five libraries working together for pure CSS/HTML interactive experiences:
-
Keys.js - Keyboard input tracking
--key-space,--key-up,--key-down, etc.
-
Cursor.js - Mouse position tracking
--cursor-x,--cursor-y,--cursor-speed, etc.
-
Touch.js - Touch gesture tracking
--touch-x,--touch-velocity-x,--touch-distance, etc.
-
Motion.js - Time/animation tracking
--motion-progress,--motion-time,--motion-loop, etc.
-
State.js ⭐ - UI state & data binding
--state-health,--state-score,--state-level, etc.
<div id="game"
data-state
data-state-watch="health,score"
data-health="100"
data-score="0"
data-cursor
data-cursor-var="true"
data-keys
data-keys-watch="space,up,down">
<!-- Health bar follows cursor -->
<div class="health-bar" style="
width: var(--state-health-percent);
transform: translateY(var(--cursor-y));
"></div>
<!-- Score pulses when space pressed -->
<div class="score" style="
transform: scale(calc(1 + var(--key-space) * 0.5));
">
Score: <span data-state-value="score"></span>
</div>
</div>
<style>
/* When health is low AND cursor is idle */
body.cursor-idle [data-health="10"],
body.cursor-idle [data-health="20"] {
animation: warning-pulse 1s infinite;
}
/* When up arrow pressed AND health full */
.key-up[data-health="100"] {
animation: victory-jump 0.5s ease-out;
}
</style>Result: A complete interactive UI system with dynamic data, user input tracking, and reactive animations - all in CSS! Perfect for games, dashboards, data visualizations, and interactive experiences.
State.js uses modern browser APIs:
- IntersectionObserver API
- MutationObserver API
- CSS Custom Properties
Supported browsers:
- Chrome/Edge 58+
- Firefox 55+
- Safari 12.1+
- Opera 45+
State.js is optimized for performance:
- ✅ Passive event listeners
- ✅ requestAnimationFrame for DOM updates
- ✅ Map-based attribute caching
- ✅ Conditional updates (only when values change)
- ✅ Efficient MutationObserver usage
Check out the documentation page code as an example: https://github.com/iDev-Games/State-JS/blob/master/index.html
Declarative over Imperative
State.js follows the same philosophy as all iDev Games libraries:
- ✅ Describe what you want (HTML data attributes)
- ✅ Style how it looks (CSS)
- ❌ No complex JavaScript APIs to learn
- ❌ No framework dependencies
The goal: Enable developers to build reactive, data-driven interfaces using HTML and CSS skills they already have - whether for dashboards, web apps, visualizations, or games.
MIT License - see LICENSE file for details
iDev Games
- GitHub: @iDev-Games
- Dev.to: @idevgames
Contributions, issues, and feature requests are welcome!
Feel free to check the issues page.
Give a ⭐️ if this project helped you!
