Build immersive VR and AR experiences for the web using A-Frame, the HTML-based 3D framework built on Three.js. Use this skill whenever the user is building or working on an A-Frame scene, writing A-Frame components, adding WebXR interactivity, setting up VR controllers, loading GLTF models in A-Frame, building a 360° viewer, creating a VR game, handling raycasting or gaze interactions, or asking about a-scene, a-entity, a-box, a-sky, AFRAME.registerComponent, or any other A-Frame primitive or API. Also triggers on "WebXR scene", "VR on the web", "immersive web", "A-Frame entity", "aframe component", "meta quest webxr", "VR experience HTML", or "a-frame physics".
A-Frame is a web framework for building virtual reality (VR) and augmented reality (AR) experiences. It wraps Three.js in an HTML-based entity-component-system (ECS) architecture so you can write <a-box> in markup and get a 3D scene — while still having full access to JavaScript, Three.js, and the WebXR API when you need it.
Current stable version: 1.7.x (CDN: https://aframe.io/releases/1.7.1/aframe.min.js)
Docs: https://aframe.io/docs/1.7.0/introduction/
<html>
<head>
<script src="https://aframe.io/releases/1.7.1/aframe.min.js"></script>
</head>
<body>
<a-scene>
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
<a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
<a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
<a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
<a-sky color="#ECECEC"></a-sky>
</a-scene>
</body>
</html>
No build step, no install. Drop a <script> tag and write HTML. Open in any browser — including on a Meta Quest — and click "Enter VR."
A-Frame follows an ECS architecture: the building blocks are entities (container objects), components (reusable data/behavior modules), and systems (global managers).
<a-entity> — a bare entity with no appearance or behavior until components are attachedgeometry="primitive: box", material="color: red", light="type: point"<!-- A glowing point-light sphere -->
<a-entity
geometry="primitive: sphere; radius: 0.5"
material="color: white; shader: flat; emissive: #FFEEAA; emissiveIntensity: 0.8"
light="type: point; color: white; intensity: 1.5; distance: 5"
position="0 2 -3">
</a-entity>
Primitives are shorthand wrappers around common entity+component combos:
| Primitive | Equivalent entity components |
|---|---|
<a-box> | geometry="primitive: box" + material |
<a-sphere> | geometry="primitive: sphere" |
<a-plane> | geometry="primitive: plane" |
<a-sky> | Large inverted sphere + material="side: back" |
<a-gltf-model> | gltf-model component |
<a-text> | text component |
<a-camera> | camera + look-controls + wasd-controls |
<a-cursor> | cursor + raycaster (gaze interaction) |
<a-light> | light component |
<a-sound> | sound component |
Preload assets in <a-assets> so the scene only starts rendering when everything is ready:
<a-scene>
<a-assets>
<a-asset-item id="tree-model" src="tree.gltf"></a-asset-item>
<img id="grass-texture" src="grass.jpg">
<audio id="ambient-sound" src="forest.mp3" preload="auto"></audio>
</a-assets>
<!-- Reference assets by ID -->
<a-gltf-model src="#tree-model" position="0 0 -5"></a-gltf-model>
<a-plane material="src: #grass-texture" width="20" height="20" rotation="-90 0 0"></a-plane>
<a-sound src="#ambient-sound" autoplay="true" loop="true"></a-sound>
</a-scene>
All assets get a crossorigin attribute automatically. Set a timeout with <a-assets timeout="10000"> (default 3000ms). Listen for the loaded event if you need to act after all assets finish.
GLTF 2.0 is A-Frame's preferred 3D model format:
<!-- As primitive -->
<a-gltf-model src="url(model.gltf)" position="0 0 -3" scale="0.5 0.5 0.5"></a-gltf-model>
<!-- As component on entity -->
<a-entity gltf-model="url(model.gltf)" position="0 0 -3"></a-entity>
<!-- Or reference a preloaded asset -->
<a-gltf-model src="#my-model"></a-gltf-model>
Listen for the model to load before modifying its children:
document.querySelector('a-gltf-model').addEventListener('model-loaded', function (evt) {
var model = evt.detail.model; // THREE.Group
console.log('Model loaded:', model);
});
Tip: Use GLB (binary GLTF) for single-file deployment. Keep models under 5MB for web performance.
All application logic belongs inside components — this keeps code modular, reusable, and testable. Define components before <a-scene>:
<head>
<script src="https://aframe.io/releases/1.7.1/aframe.min.js"></script>
<script src="my-component.js"></script> <!-- ← before a-scene -->
</head>
AFRAME.registerComponent('my-component', {
// Define configurable properties
schema: {
color: { type: 'color', default: '#FF0000' },
speed: { type: 'number', default: 1 },
target: { type: 'selector' }, // returns an element
enabled: { type: 'boolean', default: true }
},
// Called once when the component first attaches to an entity
init: function () {
// Set up state, allocate objects you'll reuse in tick()
this.velocity = new THREE.Vector3();
this.clock = 0;
},
// Called after init() and whenever data changes
update: function (oldData) {
var diff = AFRAME.utils.diff(oldData, this.data); // what changed
if ('color' in diff) {
this.el.getObject3D('mesh').material.color.set(this.data.color);
}
},
// Called on every frame. Keep it lean — no garbage!
tick: function (time, timeDelta) {
if (!this.data.enabled) { return; }
this.clock += timeDelta;
// timeDelta is milliseconds since last frame
},
// Called when the component is removed or entity is removed
remove: function () {
// Clean up event listeners, cancel timers, dispose Three.js objects
},
// Called when entity pauses (e.g., tab hidden)
pause: function () {},
play: function () {}
});
Use the component in HTML:
<a-entity my-component="color: blue; speed: 2; target: #player"></a-entity>
Never allocate objects inside tick() — create THREE.Vector3, THREE.Quaternion etc. once in init() and reuse them. Garbage collection pauses in VR cause visible judder:
// ✅ Allocate once in init()