A-Frame Components: Encapsulating Reusable VR Logic Without Global State
Stop scattering scene logic across global scripts. A-Frame’s component system lets you bundle behavior, lifecycle, and cleanup into declarative HTML attributes—here’s how to use it well.
12 Jun 2026, 01:28 UTC

The problem: scene logic leaks everywhere
You start with a simple A-Frame scene: a few boxes, a camera, a light. Then the requirements grow. Ten entities need to orbit. Five need to pulse. Three need to listen for controller events. The fastest path is a <script> block with document.querySelectorAll, a global array of meshes, and a single animation loop that reaches into the DOM by ID. It works until you remove an entity—then a listener fires on a detached node, or a mesh stays in memory because something still references it.
The useful takeaway: A-Frame’s component system lets you attach behavior declaratively, so each entity carries its own logic and cleanup. You stop managing global state and start composing scenes from small, testable units.
Components are entity-scoped modules, not global helpers
In A-Frame, an entity is an HTML element. The light attribute, the geometry attribute, and your custom attributes are all components. When you call AFRAME.registerComponent('rotating-light', {...}), you are teaching the scene a new vocabulary word. Any <a-entity> that uses rotating-light="speed: 45" gets that behavior.
Inside a component, this.el is the entity. Through it you reach the three.js object graph without global variables. You also get lifecycle callbacks:
initruns once when the component is attached.updateruns when the component’s data changes, including the first attach.tickruns every frame.removeruns when the component is detached or the entity is removed.
These hooks are the difference between a script that grows brittle and a component that cleans up after itself.
Worked example: a rotating-light component
Suppose you want point lights that orbit a scene at different speeds. Instead of a global animation loop, define the behavior once.
In your HTML:
<a-scene>
<a-entity light="type: point; intensity: 0.8"
position="2 3 -2"
rotating-light="speed: 45; axis: y"></a-entity>
<a-entity light="type: point; intensity: 0.5"
position="-2 3 -2"
rotating-light="speed: 20; axis: x"></a-entity>
</a-scene>
In your JavaScript, placed in a <script> tag after the A-Frame library or in a module that imports A-Frame:
AFRAME.registerComponent('rotating-light', {
schema: {
speed: {default: 30, type: 'number'},
axis: {default: 'y', oneOf: ['x', 'y', 'z']}
},
init: function () {
// The core light component has already attached a THREE.Light
// to this entity's object3D. We rotate that object3D directly.
},
tick: function (time, timeDelta) {
// timeDelta is in milliseconds; convert to seconds for stable speed.
var seconds = timeDelta / 1000;
var radians = this.data.speed * Math.PI / 180 * seconds;
this.el.object3D.rotation[this.data.axis] += radians;
},
update: function (oldData) {
// No special handling needed: tick reads this.data every frame,
// so changing speed or axis at runtime takes effect immediately.
},
remove: function () {
// If this component had added window event listeners, created
// geometries, or started intervals, we would tear them down here.
}
});
Where to run this and what to check
No special permissions are required beyond normal browser execution. To verify it loaded:
- Open the browser console. If A-Frame logs a warning about an unknown component, the registration failed.
- Inspect the entity in DevTools. You should see
rotating-light="speed: 45; axis: y"as an attribute on the<a-entity>element. - Confirm the light rotates smoothly. On desktop, aim for 60 fps; on mobile, expect lower frame rates and test accordingly.
Trade-offs: when components hurt more than they help
Components are not free. The biggest risk is tick. Because tick runs every frame, even small allocations—creating a new THREE.Vector3, instantiating an object, or calling querySelector—can cause garbage-collection pauses that stutter the scene. Keep tick bodies tight and reuse pre-allocated variables when possible.
Naming is another trap. Component names share a single global registry. If you publish rotating-light and later install a third-party library that also defines rotating-light, one will overwrite the other. Prefix your components, for example myapp-rotating-light, to avoid collisions.
Finally, A-Frame’s API evolves. Lifecycle names or schema defaults can change between major versions. Pin your A-Frame version in package.json or via CDN, and run your scene’s smoke tests after any upgrade rather than assuming old components still behave identically.
A practical way to start
Pick the most repetitive behavior in your current scene—the one you copy-pasted three times—and extract it into a component today. Give it a schema so values are declarative. Put per-frame math in tick and nothing else. If you allocate anything outside the entity’s own object graph, write the remove teardown immediately, even if it feels unnecessary. Then load the scene, watch the console for errors, and confirm the attribute appears on the entity in DevTools. If the light spins and the markup stays clean, the pattern is working.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.