Build a product configurator
This tutorial builds a complete product configurator, a model on a turntable, swatches that switch its colorways instantly, and a small showroom lighting rig that reacts to the selection. It is the declarative API’s sweet spot, the whole scene is a function of one piece of state.
Here is the finished result. Tap the swatches.
The model is the Khronos sample asset MaterialsVariantsShoe (by Shopify, CC BY 4.0). It declares three KHR_materials_variants colorways, Midnight, Beach, and Street, which is the glTF feature product configurators are built on. One mesh carries several material sets, switched at runtime without reloading anything.
1. Mount the model
Section titled “1. Mount the model”Start from the first scene skeleton and put a SceneModel inside a SceneView.declarative. Add the shoe to your app’s assets (assets/MaterialsVariantsShoe.glb in pubspec.yaml).
class Configurator extends StatefulWidget { const Configurator({super.key});
@override State<Configurator> createState() => _ConfiguratorState();}
class _ConfiguratorState extends State<Configurator> { @override Widget build(BuildContext context) { return SceneView.declarative( cameraBuilder: (elapsed) { final t = elapsed.inMicroseconds / 1e6; final yaw = pi * 0.94 + sin(t * 0.22) * 0.10; return PerspectiveCamera( position: vm.Vector3(sin(yaw) * 3.1, 1.45, cos(yaw) * 3.1), target: vm.Vector3(0, 0.52, 0), fovRadiansY: 35 * pi / 180, ); }, children: [ SceneModel( 'assets/MaterialsVariantsShoe.glb', scale: vm.Vector3.all(8.0), ), ], ); }}SceneModel loads and imports the .glb asynchronously and mounts it when ready. The camera holds a fixed product-shot framing with a slow breathing sway rather than orbiting; the product itself will rotate instead.
2. Put it on a turntable
Section titled “2. Put it on a turntable”Attach a component for the rotation, motion runs engine-side, so nothing rebuilds per frame.
class TurntableComponent extends Component { TurntableComponent(this.radiansPerSecond);
final double radiansPerSecond;
@override void update(double deltaSeconds) { node.localTransform.rotateY(radiansPerSecond * deltaSeconds); node.markTransformDirty(); }}Keep the instance stable in your State (components are diffed by identity) and add it to the model.
late final _turntable = TurntableComponent(0.45);
// In buildSceneModel( 'assets/MaterialsVariantsShoe.glb', scale: vm.Vector3.all(8.0), components: [_turntable],)3. Switch variants from state
Section titled “3. Switch variants from state”This is the heart of the configurator, and it is three lines. Hold the selected variant name in state and pass it to the variant prop.
String _variant = 'midnight';
// In buildSceneModel( 'assets/MaterialsVariantsShoe.glb', variant: _variant, scale: vm.Vector3.all(8.0), components: [_turntable],)Any UI that calls setState(() => _variant = 'beach') now recolors the shoe. Selection swaps the mapped materials in place, no reload, no re-upload, so it is instant. (Imperative code can do the same through MaterialsVariantsComponent.of(model)?.select('beach'); see Materials.)
A simple swatch row looks like this.
Row( children: [ for (final name in const ['midnight', 'beach', 'street']) TextButton( onPressed: () => setState(() => _variant = name), child: Text(name), ), ],)Stack it over the SceneView and the interaction loop is complete.
4. Stage it
Section titled “4. Stage it”A product deserves better than the default full studio lighting. Dim the environment so your own lights carry the image, and build a small rig, a warm shadow-casting key spot, a colored rim light behind the product, and a glossy pedestal to catch both.
late final _keyLight = SpotLight( color: vm.Vector3(1.0, 0.96, 0.9), intensity: 65.0, range: 30.0, direction: vm.Vector3(-1.3, -3.0, 1.9).normalized(), innerConeAngle: 12 * pi / 180, outerConeAngle: 38 * pi / 180, castsShadow: true, shadowSoftness: 2.0,);late final _rimLight = PointLight( color: vm.Vector3(0.24, 0.48, 1.0), intensity: 9.0, range: 14.0,);late final _pedestalGeometry = CylinderGeometry( bottomRadius: 1.75, topRadius: 1.6, height: 0.12, radialSegments: 64,);late final _pedestalMaterial = PhysicallyBasedMaterial() ..baseColorFactor = vm.Vector4(0.045, 0.045, 0.055, 1.0) ..metallicFactor = 0.85 ..roughnessFactor = 0.32;SceneView.declarative( environmentIntensity: 0.35, exposure: 1.45, cameraBuilder: ..., children: [ SceneNode( position: vm.Vector3(1.3, 3.3, -1.9), components: [SpotLightComponent(_keyLight)], ), SceneNode( position: vm.Vector3(-2.7, 1.3, 2.3), components: [PointLightComponent(_rimLight)], ), SceneMesh( geometry: _pedestalGeometry, material: _pedestalMaterial, position: vm.Vector3(0, -0.06, 0), ), SceneModel(...), ],)Lights are just components on nodes, so they are declared like everything else, and the node’s transform places and aims them.
5. Make the lighting react
Section titled “5. Make the lighting react”For the finishing touch, glide the rim light toward a matching accent color when the colorway changes. This is the “mutate for motion” pattern again, the component eases every frame, and the swatch tap only retargets it.
class ColorGlideComponent extends Component { ColorGlideComponent(this.light, this.target);
final PointLight light; vm.Vector3 target;
@override void update(double deltaSeconds) { final blend = 1.0 - exp(-deltaSeconds * 6.0); final color = light.color; color.x += (target.x - color.x) * blend; color.y += (target.y - color.y) * blend; color.z += (target.z - color.z) * blend; }}late final _rimGlide = ColorGlideComponent(_rimLight, vm.Vector3(0.24, 0.48, 1.0));
// Attach it beside the lightSceneNode( position: vm.Vector3(-2.7, 1.3, 2.3), components: [PointLightComponent(_rimLight), _rimGlide],)
// And retarget on selectiononPressed: () => setState(() { _variant = 'beach'; _rimGlide.target = vm.Vector3(1.0, 0.48, 0.32);}),That is the whole configurator, one state variable, a handful of declared nodes, and two small components. The demo at the top of this page is exactly this code plus swatch styling.
Where to go from here
Section titled “Where to go from here”- Ship it with a loading screen. Give the view
warmUp: trueand aloadingBuilder, and the reveal waits for the model (Assets and loading). - Models converted offline to
.fscenebkeep their variants, so the same configurator works with preprocessed assets (Assets and loading). - Add tap-to-inspect with Picking and input, or annotate hotspots with Widgets in the scene.
- Animated products (an opening lid, a folding mechanism) drive imported animations declaratively with
SceneModel.animations(Animation).