Skip to content

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.

A shoe with three material variants, a shadow-casting key light, and a colorway-reactive rim light.

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.

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.

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 build
SceneModel(
'assets/MaterialsVariantsShoe.glb',
scale: vm.Vector3.all(8.0),
components: [_turntable],
)

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 build
SceneModel(
'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.

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.

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 light
SceneNode(
position: vm.Vector3(-2.7, 1.3, 2.3),
components: [PointLightComponent(_rimLight), _rimGlide],
)
// And retarget on selection
onPressed: () => 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.

  • Ship it with a loading screen. Give the view warmUp: true and a loadingBuilder, and the reveal waits for the model (Assets and loading).
  • Models converted offline to .fsceneb keep 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).