Skip to content

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

All triggers

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

FormatRole
glTF / GLBCanonical. GLB (binary) preferred for single-file delivery. Draco / meshopt compression REQUIRED above 1 MB.
USDZCompanion file for iOS/visionOS AR Quick Look only. Generated from the GLB; never the source of truth.
Poster imageMANDATORY. 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

GestureAction
Drag / arrow keysOrbit
Wheel / pinchZoom (clamped min/max)
Two-finger / right-dragPan (opt-in; off by default)
Auto-rotateOptional 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:

  1. Render the poster image immediately (correct aspect, no layout shift — like cards.kmd § R5).
  2. Defer the viewer runtime + model until the element enters the viewport (IntersectionObserver) or the user activates the poster.
  3. Show a determinate progress indicator while the GLB downloads.

Budgets:

BudgetTarget
GLB transfer size≤ 2 MB recommended; > 5 MB blocks release (warn)
First interactive< 1.5 s on mid-range mobile after activation
Frame rate60 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:

PlatformMechanism
AndroidScene Viewer (GLB)
iOS / iPadOS / visionOSAR Quick Look (USDZ)
WebXR browsersWebXR session (opt-in)
UnsupportedButton 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/desktopKoderModelViewer widget (Filament / model_viewer_plus backend); 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-motion or 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

  • themes/depth.kmd — the viewer sits on the z-axis like any surface
  • themes/lighting.kmd — shared light rig (R4) + cast shadow
  • themes/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 KoderModelViewer with src and poster set, 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 alt attribute

Scenario: GLB is deferred until the element is activated

  • GIVEN a KoderModelViewer outside 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 KoderModelViewer with auto-rotate enabled
  • 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 KoderModelViewer auto-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 KoderModelViewer renders
  • 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 KoderModelViewer renders with ar enabled 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 KoderModelViewer with camera-controls enabled and a non-empty alt
  • 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 alt text

Scenario: Forced-colors falls back the chrome

  • GIVEN a KoderModelViewer rendered with forced-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 KoderModelViewer fully 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 KoderModelViewer configured with both src (GLB) and usdz
  • 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