Model viewer
components specs/components/model-viewer.kmd
Interactive 3D model viewer — embed a glTF/GLB mesh that the user can orbit, zoom and (on capable devices) place in AR. Self-hosted viewer (no third-party SaaS), poster-first lazy loading, reduced-motion + image fallback. No Material parity (M3 has no 3D viewer); Koder fills the gap for product catalogs, hardware showcases and Hub hero assets.
When this spec applies
Primary triggers
- Add a 3D model viewer to any Koder surface
All triggers
- Show a 3D product / hardware / asset that the user can rotate
- Add an AR 'view in your space' affordance
- Embed a glTF/GLB/USDZ model on web, desktop or mobile
Specification body
Spec — Model viewer
Facet Visual + Integration do Koder Design. No Material 3 equivalent — Koder addition. Built on the self-hosted
<model-viewer>web component (glTF) + native quick-look on mobile.
KoderModelViewer embeds a real 3D mesh the user can orbit, zoom and
inspect, with an optional AR "place in your space" mode. It is the only
component that renders true geometry, so it is the on-ramp for the
3D / depth.kmd story on flat surfaces.
Use cases
- Product catalog — papelaria e-commerce (Crescer / Vivver) item preview; rotate before buy.
- Hardware / device showcase — Koder Kodix device, peripheral.
- Hub hero asset — an app's 3D icon or mascot.
- Spec/asset preview — inspect a 3D model bundled with a product.
R1 — Formats
| Format | Role |
|---|---|
| glTF / GLB | Canonical. GLB (binary) preferred for single-file delivery. Draco / meshopt compression REQUIRED above 1 MB. |
| USDZ | Companion file for iOS/visionOS AR Quick Look only. Generated from the GLB; never the source of truth. |
| Poster image | MANDATORY. A static render (.webp / .png / .svg) shown before/instead of the live model (R3). |
Self-hosted per self-hosted-first.kmd:
the viewer runtime is bundled locally and served under script-src 'self'. No CDN, no model-hosting SaaS. Canonical web impl:
<koder-model-viewer> (the koder-model-viewer.js wrapper) over the
self-hosted @google/model-viewer engine vendored at
/assets/vendor/model-viewer/. DRACO / meshopt decoders, if used,
MUST also be self-hosted (they default to a Google CDN) — prefer
uncompressed glTF for small assets to avoid that external decoder path.
R2 — Interaction
| Gesture | Action |
|---|---|
| Drag / arrow keys | Orbit |
| Wheel / pinch | Zoom (clamped min/max) |
| Two-finger / right-drag | Pan (opt-in; off by default) |
| Auto-rotate | Optional idle spin; off under prefers-reduced-motion |
Auto-rotate, when on, spins slowly (≤ 30°/s, matching the XR motion-
sickness ceiling, xr-preview.kmd § R8) and stops on interaction.
R3 — Loading & performance
Poster-first, always lazy:
- Render the poster image immediately (correct aspect, no layout
shift — like
cards.kmd § R5). - Defer the viewer runtime + model until the element enters the viewport (IntersectionObserver) or the user activates the poster.
- Show a determinate progress indicator while the GLB downloads.
Budgets:
| Budget | Target |
|---|---|
| GLB transfer size | ≤ 2 MB recommended; > 5 MB blocks release (warn) |
| First interactive | < 1.5 s on mid-range mobile after activation |
| Frame rate | 60 fps orbit; degrade mesh LOD before dropping frames |
| Wrapper JS (eager) | ≤ 5 KB gzipped (koder-model-viewer.js) — safe on the critical path |
| Engine JS (lazy) | @google/model-viewer ≈ 285 KB gzipped (three.js-based); MUST be lazy-loaded on poster activation / viewport entry, never on the critical path, loaded once + shared |
R4 — Lighting, depth & theme
- The scene's directional light reads the rig in
lighting.kmd § R5(azimuth/elevation) so a model and the cards around it are lit from the same angle. - Ground/contact shadow uses the umbra+penumbra pair (
lighting.kmd § R2). - The viewer sits at a depth level like any surface
(
depth.kmd); its container shadow follows elevation. - Environment / IBL swaps with light/dark mode (brighter neutral env in
light; dimmer in dark). Background defaults to
transparent(inherits surface) — never a baked-in colour.
R5 — AR mode
"View in your space" button, shown only when supported:
| Platform | Mechanism |
|---|---|
| Android | Scene Viewer (GLB) |
| iOS / iPadOS / visionOS | AR Quick Look (USDZ) |
| WebXR browsers | WebXR session (opt-in) |
| Unsupported | Button hidden — never a dead control |
AR is not themed (uses OS-native UI) and is never the only way to see the model.
R6 — Accessibility
aria-label/ alt REQUIRED — a text description of the model (the model is never the sole carrier of information; pair with caption or spec text).- Keyboard orbit via arrow keys; viewer is focusable with a visible ring.
- Poster image has its own
alt. prefers-reduced-motion→ no auto-rotate, no entry animation; the poster + manual orbit remain.- Respects
forced-colors— the chrome (buttons, progress) falls back to system colours; the 3D canvas is exempt.
R7 — Platform binding (koder_kit)
- Web / Flutter Web —
<koder-model-viewer src= poster= alt=>wrapping the self-hosted<model-viewer>element. - Flutter mobile/desktop —
KoderModelViewerwidget (Filament /model_viewer_plusbackend); same poster-first + AR contract. - Shared attributes:
src,usdz,poster,alt,auto-rotate,ar,camera-controls,max-zoom.
R8 — Forbidden patterns
- ❌ No poster (blank box during load / on failure).
- ❌ Auto-rotate that ignores
prefers-reduced-motionor never stops. - ❌ Model as the only source of essential info (R6).
- ❌ Unoptimized multi-MB GLB shipped without Draco/meshopt (R3).
- ❌ Third-party model-hosting SaaS or CDN runtime (R1, self-hosted).
- ❌ Baked background colour that ignores light/dark (R4).
- ❌ Blocking the main thread on load instead of lazy + progress (R3).
T1-T5 — Tests
T1 — Poster fallback: with JS disabled / before activation, the poster
renders at the correct aspect with valid alt.
T2 — Lazy load: the GLB is not fetched until the element is in
viewport or activated (network assertion).
T3 — Reduced-motion: with prefers-reduced-motion, no auto-rotate and
no entry animation occur.
T4 — AR gating: AR button is absent on a platform reporting no AR
support (no dead control).
T5 — Lighting coherence: scene directional light angle equals
--kds-light-azimuth/elevation within 1° (lighting.kmd § T3).
Live demo
Cross-link
themes/depth.kmd— the viewer sits on the z-axis like any surfacethemes/lighting.kmd— shared light rig (R4) + cast shadowthemes/motion/physics.kmd— reduced-motion contract (R2/R6)components/cards.kmd— product card hosting a model viewer (R5 media)policies/self-hosted-first.kmd— bundled runtime, no SaaS (R1)
Requirements (testable)
Requirement: Poster-first lazy loading {#req-model-viewer-poster-lazy}
The viewer SHALL render the mandatory poster image immediately at the correct aspect ratio and SHALL NOT fetch the GLB model or load the 3D engine runtime until the element enters the viewport or the user activates the poster; while the model downloads it SHALL show a determinate progress indicator.
Scenario: Poster renders before activation
- GIVEN a
KoderModelViewerwithsrcandposterset, placed below the fold - WHEN the page loads and the element is not yet scrolled into view
- THEN the poster image is displayed at the model's aspect ratio with no layout shift
- AND the poster carries a valid non-empty
altattribute
Scenario: GLB is deferred until the element is activated
- GIVEN a
KoderModelVieweroutside the viewport with no user activation - WHEN the network requests made by the page are inspected
- THEN no request for the GLB model URL has been issued
- AND no request for the lazy 3D engine JS (
@google/model-viewer) has been issued - BUT the wrapper JS (
koder-model-viewer.js, ≤ 5 KB gzipped) MAY already be present
Scenario: Progress shown during model download
- GIVEN the poster has been activated and the GLB is downloading
- WHEN the download is in flight
- THEN a determinate progress indicator is visible reflecting bytes loaded
Requirement: Reduced-motion suppresses auto-rotate {#req-model-viewer-reduced-motion}
When prefers-reduced-motion is active the viewer SHALL NOT auto-rotate and SHALL NOT play any entry animation; when auto-rotate is enabled it SHALL spin at no more than 30°/s and SHALL stop as soon as the user interacts.
Scenario: No auto-rotate under reduced motion
- GIVEN a
KoderModelViewerwithauto-rotateenabled - AND the environment reports
prefers-reduced-motion: reduce - WHEN the viewer is loaded and left idle
- THEN the model does not spin
- AND no entry animation is played
Scenario: Auto-rotate stops on interaction
- GIVEN a
KoderModelViewerauto-rotating with reduced-motion not set - WHEN the user drags to orbit the model
- THEN auto-rotation stops
- AND the measured idle spin rate before interaction was ≤ 30°/s
Requirement: AR affordance gated by support {#req-model-viewer-ar-gating}
The "view in your space" AR button SHALL be shown only on platforms that report AR support (Scene Viewer on Android, AR Quick Look on iOS/iPadOS/visionOS, WebXR where opted in) and SHALL be absent — never a disabled or dead control — on platforms with no AR support, and AR SHALL never be the only way to view the model.
Scenario: AR button hidden without support
- GIVEN a platform that reports no AR capability
- WHEN the
KoderModelViewerrenders - THEN the AR button is absent from the DOM / view tree
- AND the model remains inspectable via orbit and zoom
Scenario: AR button present with support
- GIVEN an Android platform that reports Scene Viewer support
- WHEN the
KoderModelViewerrenders witharenabled and a valid model - THEN the AR button is present and actionable
Requirement: Accessibility and keyboard orbit {#req-model-viewer-a11y}
The viewer SHALL expose a non-empty aria-label/alt text description of the model, SHALL be keyboard-focusable with a visible focus ring and orbitable via the arrow keys, and SHALL respect forced-colors by falling back the chrome (buttons, progress) to system colours while exempting the 3D canvas.
Scenario: Label and keyboard orbit
- GIVEN a
KoderModelViewerwithcamera-controlsenabled and a non-emptyalt - WHEN the viewer receives keyboard focus and the user presses the arrow keys
- THEN a visible focus ring is shown on the viewer
- AND the camera orbits in response to the arrow keys
- AND the exposed accessible name equals the provided
alttext
Scenario: Forced-colors falls back the chrome
- GIVEN a
KoderModelViewerrendered withforced-colors: active - WHEN the chrome (AR button, progress indicator) is inspected
- THEN those controls use system colours
- BUT the 3D canvas itself is left unchanged
Requirement: Self-hosted runtime and formats {#req-model-viewer-self-hosted}
The viewer runtime, the 3D engine, and any DRACO/meshopt decoders SHALL be served from the same origin under script-src 'self' with no third-party CDN or model-hosting SaaS, and the canonical source model SHALL be glTF/GLB with USDZ used only as a generated companion for AR Quick Look.
Scenario: No external runtime or model host
- GIVEN a
KoderModelViewerfully loaded and activated - WHEN every script and decoder request it issues is inspected
- THEN all runtime, engine, and DRACO/meshopt decoder requests target the page's own origin
- AND no request goes to a third-party CDN or model-hosting SaaS
Scenario: USDZ is companion-only
- GIVEN a
KoderModelViewerconfigured with bothsrc(GLB) andusdz - WHEN the live orbit/zoom view is rendered on the web surface
- THEN the geometry displayed is loaded from the GLB
src - AND the USDZ file is used only when launching AR Quick Look
References
specs/themes/depth.kmdspecs/themes/lighting.kmdspecs/themes/motion/physics.kmdspecs/components/cards.kmdspecs/policies/self-hosted-first.kmd