diff --git a/package.json b/package.json
index 58063c0f529d6e7e66126062b60c766bf529dfca..0a94d9001e0050e427120077d692d1b8dab0eb35 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
     "watch": "concurrently --kill-others \"npm:watch-ts\" \"npm:watch-extra\" \"npm:watch-webpack\"",
     "watch-ts": "tsc -watch",
     "watch-extra": "cpx \"src/**/*.{vert,frag,glsl,scss,woff,woff2,ttf,otf,eot,svg,html,gql}\" build/src/ --watch",
-    "build-webpack": "webpack --mode development",
+    "build-webpack": "webpack --mode production",
     "watch-webpack": "webpack -w --mode development",
     "model-server": "node build/src/servers/model/server.js",
     "model-server-watch": "nodemon --watch build/src build/src/servers/model/server.js"
diff --git a/src/apps/basic-wrapper/index.html b/src/apps/basic-wrapper/index.html
index b4a202a2dddd64da6f63e70dab00eb33de5e9744..07592b99d310d9376a10be671fa594b4314e01e0 100644
--- a/src/apps/basic-wrapper/index.html
+++ b/src/apps/basic-wrapper/index.html
@@ -12,37 +12,94 @@
             }
             #app {
                 position: absolute;
-                left: 100px;
+                left: 160px;
                 top: 100px;
                 width: 600px;
                 height: 600px;
                 border: 1px solid #ccc;
             }
+
+            #controls {
+                position: absolute;
+                width: 130px;
+                top: 10px;
+                left: 10px;
+            }
+
+            #controls > button {
+                display: block;
+                width: 100%;
+                text-align: left;
+            }
+
+            #controls > hr {
+                margin: 5px 0;
+            }
         </style>
         <link rel="stylesheet" type="text/css" href="app.css" />
         <script type="text/javascript" src="./index.js"></script>
     </head>
     <body>
-        <button id='spin'>Toggle Spin</button>
-        <button id='asym'>Load Asym Unit</button>
-        <button id='asm'>Load Assemly 1</button>
+        <div id='controls'>
+
+        </div>
         <div id="app"></div>
         <script>            
-            var pdbId = '5ire', assemblyId= '1';
+            var pdbId = '1grm', assemblyId= '1';
             var url = 'https://www.ebi.ac.uk/pdbe/static/entry/' + pdbId + '_updated.cif';
             var format = 'cif';
 
             // var url = 'https://www.ebi.ac.uk/pdbe/entry-files/pdb' + pdbId + '.ent';
             // var format = 'pdb';
+            // var assemblyId = 'deposited';
 
             BasicMolStarWrapper.init('app' /** or document.getElementById('app') */);
             BasicMolStarWrapper.setBackground(0xffffff);
             BasicMolStarWrapper.load({ url: url, format: format, assemblyId: assemblyId });
             BasicMolStarWrapper.toggleSpin();
 
-            document.getElementById('spin').onclick = () => BasicMolStarWrapper.toggleSpin();
-            document.getElementById('asym').onclick = () => BasicMolStarWrapper.load({ url: url, format: format });
-            document.getElementById('asm').onclick = () => BasicMolStarWrapper.load({ url: url, format: format, assemblyId: assemblyId });
+            addHeader('Source');
+            addControl('Load Asym Unit', () => BasicMolStarWrapper.load({ url: url, format: format }));
+            addControl('Load Assembly 1', () => BasicMolStarWrapper.load({ url: url, format: format, assemblyId: assemblyId }));
+
+            addSeparator();
+
+            addHeader('Camera');
+            addControl('Toggle Spin', () => BasicMolStarWrapper.toggleSpin());
+            
+            addSeparator();
+
+            addHeader('Animation');
+
+            // adjust this number to make the animation faster or slower
+            // requires to "restart" the animation if changed
+            BasicMolStarWrapper.animate.modelIndex.maxFPS = 4;
+
+            addControl('Play To End', () => BasicMolStarWrapper.animate.modelIndex.onceForward());
+            addControl('Play To Start', () => BasicMolStarWrapper.animate.modelIndex.onceBackward());
+            addControl('Play Palindrome', () => BasicMolStarWrapper.animate.modelIndex.palindrome());
+            addControl('Play Loop', () => BasicMolStarWrapper.animate.modelIndex.loop());
+            addControl('Stop', () => BasicMolStarWrapper.animate.modelIndex.stop());
+
+            ////////////////////////////////////////////////////////
+
+            function addControl(label, action) {
+                var btn = document.createElement('button');
+                btn.onclick = action;
+                btn.innerText = label;
+                document.getElementById('controls').appendChild(btn);
+            }
+
+            function addSeparator() {
+                var hr = document.createElement('hr');
+                document.getElementById('controls').appendChild(hr);
+            }
+
+            function addHeader(header) {
+                var h = document.createElement('h3');
+                h.innerText = header;
+                document.getElementById('controls').appendChild(h);
+            }
         </script>
     </body>
 </html>
\ No newline at end of file
diff --git a/src/apps/basic-wrapper/index.ts b/src/apps/basic-wrapper/index.ts
index b45753f912eb4053dc8cf6730f085a2bdb635042..8635c265cfd7892a9d851b4ba3d9928724cac056 100644
--- a/src/apps/basic-wrapper/index.ts
+++ b/src/apps/basic-wrapper/index.ts
@@ -13,6 +13,7 @@ import { StructureRepresentation3DHelpers } from 'mol-plugin/state/transforms/re
 import { Color } from 'mol-util/color';
 import { StateTreeBuilder } from 'mol-state/tree/builder';
 import { PluginStateObject as PSO } from 'mol-plugin/state/objects';
+import { AnimateModelIndex } from 'mol-plugin/state/animation/built-in';
 require('mol-plugin/skin/light.scss')
 
 type SupportedFormats = 'cif' | 'pdb'
@@ -98,6 +99,17 @@ class BasicWrapper {
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { trackball: { ...trackball, spin: !trackball.spin } } });
         if (!spinning) PluginCommands.Camera.Reset.dispatch(this.plugin, { });
     }
+
+    animate = {
+        modelIndex: {
+            maxFPS: 8,
+            onceForward: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'once', params: { direction: 'forward' } } }) },
+            onceBackward: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'once', params: { direction: 'backward' } } }) },
+            palindrome: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'palindrome', params: {} } }) },
+            loop: () => { this.plugin.state.animation.play(AnimateModelIndex, { maxFPS: Math.max(0.5, this.animate.modelIndex.maxFPS | 0), mode: { name: 'loop', params: {} } }) },
+            stop: () => this.plugin.state.animation.stop()
+        }
+    }
 }
 
 (window as any).BasicMolStarWrapper = new BasicWrapper();
\ No newline at end of file
diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts
index 156219dead421927c46c3de787dc0a2740423850..5169f3f294581527fd08c255efce39de6923e58b 100644
--- a/src/mol-canvas3d/canvas3d.ts
+++ b/src/mol-canvas3d/canvas3d.ts
@@ -192,7 +192,7 @@ namespace Canvas3D {
             if (isIdentifying || isUpdating) return false
 
             let didRender = false
-            controls.update()
+            controls.update(currentTime);
             // TODO: is this a good fix? Also, setClipping does not work if the user has manually set a clipping plane.
             if (!camera.transition.inTransition) setClipping();
             const cameraChanged = camera.updateMatrices();
@@ -230,6 +230,7 @@ namespace Canvas3D {
         }
 
         let forceNextDraw = false;
+        let currentTime = 0;
 
         function draw(force?: boolean) {
             if (render('draw', !!force || forceNextDraw)) {
@@ -246,8 +247,8 @@ namespace Canvas3D {
         }
 
         function animate() {
-            const t = now();
-            camera.transition.tick(t);
+            currentTime = now();
+            camera.transition.tick(currentTime);
             draw(false)
             window.requestAnimationFrame(animate)
         }
diff --git a/src/mol-canvas3d/controls/trackball.ts b/src/mol-canvas3d/controls/trackball.ts
index 63234d324de095b95e09cd11ad07bd13354d4d95..b2e01c66d3206c082d0cbdd20b0da58f10eaf874 100644
--- a/src/mol-canvas3d/controls/trackball.ts
+++ b/src/mol-canvas3d/controls/trackball.ts
@@ -39,12 +39,12 @@ interface TrackballControls {
     readonly props: Readonly<TrackballControlsProps>
     setProps: (props: Partial<TrackballControlsProps>) => void
 
-    update: () => void
+    update: (t: number) => void
     reset: () => void
     dispose: () => void
 }
 namespace TrackballControls {
-    export function create (input: InputObserver, object: Object3D & { target: Vec3 }, props: Partial<TrackballControlsProps> = {}): TrackballControls {
+    export function create(input: InputObserver, object: Object3D & { target: Vec3 }, props: Partial<TrackballControlsProps> = {}): TrackballControls {
         const p = { ...PD.getDefaultValues(TrackballControlsParams), ...props }
 
         const viewport: Viewport = { x: 0, y: 0, width: 0, height: 0 }
@@ -131,7 +131,7 @@ namespace TrackballControls {
                 Vec3.normalize(rotAxis, Vec3.cross(rotAxis, rotMoveDir, _eye))
 
                 angle *= p.rotateSpeed;
-                Quat.setAxisAngle(rotQuat, rotAxis, angle )
+                Quat.setAxisAngle(rotQuat, rotAxis, angle)
 
                 Vec3.transformQuat(_eye, _eye, rotQuat)
                 Vec3.transformQuat(object.up, object.up, rotQuat)
@@ -150,7 +150,7 @@ namespace TrackballControls {
             Vec2.copy(_movePrev, _moveCurr)
         }
 
-        function zoomCamera () {
+        function zoomCamera() {
             const factor = 1.0 + (_zoomEnd[1] - _zoomStart[1]) * p.zoomSpeed
             if (factor !== 1.0 && factor > 0.0) {
                 Vec3.scale(_eye, _eye, factor)
@@ -207,9 +207,11 @@ namespace TrackballControls {
             }
         }
 
+        let lastUpdated = -1;
         /** Update the object's position, direction and up vectors */
-        function update() {
-            if (p.spin) spin();
+        function update(t: number) {
+            if (lastUpdated === t) return;
+            if (p.spin) spin(t - lastUpdated);
 
             Vec3.sub(_eye, object.position, target)
 
@@ -226,6 +228,8 @@ namespace TrackballControls {
             if (Vec3.squaredDistance(lastPosition, object.position) > EPSILON.Value) {
                 Vec3.copy(lastPosition, object.position)
             }
+
+            lastUpdated = t;
         }
 
         /** Reset object's vectors and the target vector to their initial values */
@@ -299,15 +303,14 @@ namespace TrackballControls {
         }
 
         const _spinSpeed = Vec2.create(0.005, 0);
-        function spin() {
-            _spinSpeed[0] = (p.spinSpeed || 0) / 1000;
+        function spin(deltaT: number) {
+            const frameSpeed = (p.spinSpeed || 0) / 1000;
+            _spinSpeed[0] = 60 * Math.min(Math.abs(deltaT), 1000 / 8) / 1000 * frameSpeed;
             if (!_isInteracting) Vec2.add(_moveCurr, _movePrev, _spinSpeed);
         }
 
         // force an update at start
-        update();
-
-        if (props.spin) { spin(); }
+        update(0);
 
         return {
             viewport,
diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts
index 2e53ede8c6ae73956b50ce2fc5061749bfd8a3a1..74424b9d615f8e31c7a024c893258a09e3be287c 100644
--- a/src/mol-plugin/behavior/static/state.ts
+++ b/src/mol-plugin/behavior/static/state.ts
@@ -51,7 +51,7 @@ export function SetCurrentObject(ctx: PluginContext) {
 }
 
 export function Update(ctx: PluginContext) {
-    PluginCommands.State.Update.subscribe(ctx, ({ state, tree }) => ctx.runTask(state.updateTree(tree)));
+    PluginCommands.State.Update.subscribe(ctx, ({ state, tree, doNotLogTiming }) => ctx.runTask(state.updateTree(tree, doNotLogTiming)));
 }
 
 export function ApplyAction(ctx: PluginContext) {
diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts
index d17b98dae56c01efee9f206175a789062c7e10a2..5e99045b27de4231d3ba30f5d2314216221d64b9 100644
--- a/src/mol-plugin/command.ts
+++ b/src/mol-plugin/command.ts
@@ -17,7 +17,7 @@ export const PluginCommands = {
     State: {
         SetCurrentObject: PluginCommand<{ state: State, ref: Transform.Ref }>(),
         ApplyAction: PluginCommand<{ state: State, action: StateAction.Instance, ref?: Transform.Ref }>(),
-        Update: PluginCommand<{ state: State, tree: State.Tree | State.Builder }>(),
+        Update: PluginCommand<{ state: State, tree: State.Tree | State.Builder, doNotLogTiming?: boolean }>(),
 
         RemoveObject: PluginCommand<{ state: State, ref: Transform.Ref }>(),
 
diff --git a/src/mol-plugin/component.ts b/src/mol-plugin/component.ts
index fab609d81a056aa2604fc2252da0f604c34fe72e..b49f80c1bb2d219fe334bbbf9b093bf37e66fa1e 100644
--- a/src/mol-plugin/component.ts
+++ b/src/mol-plugin/component.ts
@@ -4,41 +4,37 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { BehaviorSubject, Observable, Subject } from 'rxjs';
-import { PluginContext } from './context';
 import { shallowMergeArray } from 'mol-util/object';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
 
 export class PluginComponent<State> {
-    private _state: BehaviorSubject<State>;
-    private _updated = new Subject();
+    private _ev: RxEventHelper;
 
-    updateState(...states: Partial<State>[]): boolean {
-        const latest = this.latestState;
+    protected get ev() {
+        return this._ev || (this._ev = RxEventHelper.create());
+    }
+
+    private _state: State;
+
+    protected updateState(...states: Partial<State>[]): boolean {
+        const latest = this.state;
         const s = shallowMergeArray(latest, states);
         if (s !== latest) {
-            this._state.next(s);
+            this._state = s;
             return true;
         }
         return false;
     }
 
-    get states() {
-        return <Observable<State>>this._state;
-    }
-
-    get latestState() {
-        return this._state.value;
-    }
-
-    get updated() {
-        return <Observable<{}>>this._updated;
+    get state() {
+        return this._state;
     }
 
-    triggerUpdate() {
-        this._updated.next({});
+    dispose() {
+        if (this._ev) this._ev.dispose();
     }
 
-    constructor(public context: PluginContext, initialState: State) {
-        this._state = new BehaviorSubject<State>(initialState);
+    constructor(initialState: State) {
+        this._state = initialState;
     }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index 9125dc965485a80310fa522cb3526d059ef28ad2..ceace631931c4377eaea83565f5b8d95ac96d8db 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -98,7 +98,7 @@ export class PluginContext {
     initViewer(canvas: HTMLCanvasElement, container: HTMLDivElement) {
         try {
             this.layout.setRoot(container);
-            if (this.spec.initialLayout) this.layout.updateState(this.spec.initialLayout);
+            if (this.spec.initialLayout) this.layout.setProps(this.spec.initialLayout);
             (this.canvas3d as Canvas3D) = Canvas3D.create(canvas, container);
             PluginCommands.Canvas3D.SetSettings.dispatch(this, { settings: { backgroundColor: Color(0xFCFBF9) } });
             this.canvas3d.animate();
@@ -140,6 +140,7 @@ export class PluginContext {
         this.ev.dispose();
         this.state.dispose();
         this.tasks.dispose();
+        this.layout.dispose();
         this.disposed = true;
     }
 
diff --git a/src/mol-plugin/layout.ts b/src/mol-plugin/layout.ts
index bca2e2cd1da0b3570afb58a0b9f620c2e7530ca4..63b8f19be005def858b246ed5d7e31317289b9e4 100644
--- a/src/mol-plugin/layout.ts
+++ b/src/mol-plugin/layout.ts
@@ -42,21 +42,29 @@ interface RootState {
 }
 
 export class PluginLayout extends PluginComponent<PluginLayoutStateProps> {
+    readonly events = {
+        updated: this.ev()
+    }
+
     private updateProps(state: Partial<PluginLayoutStateProps>) {
-        let prevExpanded = !!this.latestState.isExpanded;
+        let prevExpanded = !!this.state.isExpanded;
         this.updateState(state);
         if (this.root && typeof state.isExpanded === 'boolean' && state.isExpanded !== prevExpanded) this.handleExpand();
 
-        this.triggerUpdate();
+        this.events.updated.next();
     }
 
     private root: HTMLElement;
     private rootState: RootState | undefined = void 0;
     private expandedViewport: HTMLMetaElement;
 
+    setProps(props: PluginLayoutStateProps) {
+        this.updateState(props);
+    }
+
     setRoot(root: HTMLElement) {
         this.root = root;
-        if (this.latestState.isExpanded) this.handleExpand();
+        if (this.state.isExpanded) this.handleExpand();
     }
 
     private getScrollElement() {
@@ -72,7 +80,7 @@ export class PluginLayout extends PluginComponent<PluginLayoutStateProps> {
 
             if (!body || !head) return;
 
-            if (this.latestState.isExpanded) {
+            if (this.state.isExpanded) {
                 let children = head.children;
                 let hasExp = false;
                 let viewports: HTMLElement[] = [];
@@ -167,8 +175,8 @@ export class PluginLayout extends PluginComponent<PluginLayoutStateProps> {
         }
     }
 
-    constructor(context: PluginContext) {
-        super(context, { ...PD.getDefaultValues(PluginLayoutStateParams), ...context.spec.initialLayout });
+    constructor(private context: PluginContext) {
+        super({ ...PD.getDefaultValues(PluginLayoutStateParams), ...context.spec.initialLayout });
 
         PluginCommands.Layout.Update.subscribe(context, e => this.updateProps(e.state));
 
diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts
index 5e3a349c88082113ea666c92133b15ed44591c7d..86d08ec73f24dcea3fbc75af8dffdfbd1a01dfcc 100644
--- a/src/mol-plugin/state.ts
+++ b/src/mol-plugin/state.ts
@@ -23,7 +23,6 @@ class PluginState {
     readonly behaviorState: State;
     readonly animation: PluginAnimationManager;
     readonly cameraSnapshots = new CameraSnapshotManager();
-
     readonly snapshots = new PluginStateSnapshotManager();
 
     readonly behavior = {
@@ -73,6 +72,7 @@ class PluginState {
         this.dataState.dispose();
         this.behaviorState.dispose();
         this.cameraSnapshots.dispose();
+        this.animation.dispose();
     }
 
     constructor(private plugin: import('./context').PluginContext) {
diff --git a/src/mol-plugin/state/animation/built-in.ts b/src/mol-plugin/state/animation/built-in.ts
index cc6ef04c6a3a4e4eb089e625cce42e65e5fcd807..d8f3b054feb017bec2c805531812832e41fe8054 100644
--- a/src/mol-plugin/state/animation/built-in.ts
+++ b/src/mol-plugin/state/animation/built-in.ts
@@ -9,13 +9,20 @@ import { PluginStateObject } from '../objects';
 import { StateTransforms } from '../transforms';
 import { StateSelection } from 'mol-state/state/selection';
 import { PluginCommands } from 'mol-plugin/command';
-import { ParamDefinition } from 'mol-util/param-definition';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
 
 export const AnimateModelIndex = PluginStateAnimation.create({
     name: 'built-in.animate-model-index',
     display: { name: 'Animate Model Index' },
-    params: () => ({ maxFPS: ParamDefinition.Numeric(3, { min: 0.5, max: 30, step: 0.5 }) }),
-    initialState: () => ({ frame: 1 }),
+    params: () => ({
+        mode: PD.MappedStatic('once', {
+            once: PD.Group({ direction: PD.Select('forward', [['forward', 'Forward'], ['backward', 'Backward']]) }, { isFlat: true }),
+            palindrome: PD.Group({ }),
+            loop: PD.Group({ }),
+        }, { options: [['once', 'Once'], ['palindrome', 'Palindrome'], ['loop', 'Loop']] }),
+        maxFPS: PD.Numeric(3, { min: 0.5, max: 30, step: 0.5 })
+    }),
+    initialState: () => ({} as { palindromeDirections?: { [id: string]: -1 | 1 | undefined } }),
     async apply(animState, t, ctx) {
         // limit fps
         if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) {
@@ -28,19 +35,45 @@ export const AnimateModelIndex = PluginStateAnimation.create({
 
         const update = state.build();
 
+        const params = ctx.params;
+        const palindromeDirections = animState.palindromeDirections || { };
+        let isEnd = false;
+
         for (const m of models) {
             const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
             if (!parent || !parent.obj) continue;
             const traj = parent.obj as PluginStateObject.Molecule.Trajectory;
             update.to(m.transform.ref).update(StateTransforms.Model.ModelFromTrajectory,
                 old => {
-                    let modelIndex = animState.frame % traj.data.length;
-                    if (modelIndex < 0) modelIndex += traj.data.length;
+                    const len = traj.data.length;
+                    let dir: -1 | 1 = 1;
+                    if (params.mode.name === 'once') {
+                        dir = params.mode.params.direction === 'backward' ? -1 : 1;
+                        // if we are at start or end already, do nothing.
+                        if ((dir === -1 && old.modelIndex === 0) || (dir === 1 && old.modelIndex === len - 1)) {
+                            isEnd = true;
+                            return old;
+                        }
+                    } else if (params.mode.name === 'palindrome') {
+                        if (old.modelIndex === 0) dir = 1;
+                        else if (old.modelIndex === len - 1) dir = -1;
+                        else dir = palindromeDirections[m.transform.ref] || 1;
+                    }
+                    palindromeDirections[m.transform.ref] = dir;
+
+                    let modelIndex = (old.modelIndex + dir) % len;
+                    if (modelIndex < 0) modelIndex += len;
+
+                    isEnd = isEnd || (dir === -1 && modelIndex === 0) || (dir === 1 && modelIndex === len - 1);
+
                     return { modelIndex };
                 });
         }
 
-        await PluginCommands.State.Update.dispatch(ctx.plugin, { state, tree: update });
-        return { kind: 'next', state: { frame: animState.frame + 1 } };
+        await PluginCommands.State.Update.dispatch(ctx.plugin, { state, tree: update, doNotLogTiming: true });
+
+        if (params.mode.name === 'once' && isEnd) return { kind: 'finished' };
+        if (params.mode.name === 'palindrome') return { kind: 'next', state: { palindromeDirections } };
+        return { kind: 'next', state: {} };
     }
 })
\ No newline at end of file
diff --git a/src/mol-plugin/state/animation/manager.ts b/src/mol-plugin/state/animation/manager.ts
index 8ba64a74932f7372782f4977b506c1f1b671aa5e..fc83a7f448e6177c3c4582b7910afbbf901be3c0 100644
--- a/src/mol-plugin/state/animation/manager.ts
+++ b/src/mol-plugin/state/animation/manager.ts
@@ -13,6 +13,7 @@ export { PluginAnimationManager }
 
 // TODO: pause functionality (this needs to reset if the state tree changes)
 // TODO: handle unregistered animations on state restore
+// TODO: better API
 
 class PluginAnimationManager extends PluginComponent<PluginAnimationManager.State> {
     private map = new Map<string, PluginStateAnimation>();
@@ -20,9 +21,17 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
     private _current: PluginAnimationManager.Current;
     private _params?: PD.For<PluginAnimationManager.State['params']> = void 0;
 
+    readonly events = {
+        updated: this.ev()
+    };
+
     get isEmpty() { return this.animations.length === 0; }
     get current() { return this._current!; }
 
+    private triggerUpdate() {
+        this.events.updated.next();
+    }
+
     getParams(): PD.Params {
         if (!this._params) {
             this._params = {
@@ -35,9 +44,9 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
     }
 
     updateParams(newParams: Partial<PluginAnimationManager.State['params']>) {
-        this.updateState({ params: { ...this.latestState.params, ...newParams } });
-        const anim = this.map.get(this.latestState.params.current)!;
-        const params = anim.params(this.context);
+        this.updateState({ params: { ...this.state.params, ...newParams } });
+        const anim = this.map.get(this.state.params.current)!;
+        const params = anim.params(this.context) as PD.Params;
         this._current = {
             anim,
             params,
@@ -69,6 +78,16 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
         }
     }
 
+    play<P>(animation: PluginStateAnimation<P>, params: P) {
+        this.stop();
+        if (!this.map.has(animation.name)) {
+            this.register(animation);
+        }
+        this.updateParams({ current: animation.name });
+        this.updateCurrentParams(params);
+        this.start();
+    }
+
     start() {
         this.updateState({ animationState: 'playing' });
         this.triggerUpdate();
@@ -81,11 +100,19 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
     }
 
     stop() {
+        if (typeof this._frame !== 'undefined') cancelAnimationFrame(this._frame);
         this.updateState({ animationState: 'stopped' });
         this.triggerUpdate();
     }
 
+    get isAnimating() {
+        return this.state.animationState === 'playing';
+    }
+
+    private _frame: number | undefined = void 0;
     private animate = async (t: number) => {
+        this._frame = void 0;
+
         if (this._current.startedTime < 0) this._current.startedTime = t;
         const newState = await this._current.anim.apply(
             this._current.state,
@@ -97,17 +124,17 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
         } else if (newState.kind === 'next') {
             this._current.state = newState.state;
             this._current.lastTime = t - this._current.startedTime;
-            if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate);
+            if (this.state.animationState === 'playing') this._frame = requestAnimationFrame(this.animate);
         } else if (newState.kind === 'skip') {
-            if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate);
+            if (this.state.animationState === 'playing') this._frame = requestAnimationFrame(this.animate);
         }
     }
 
     getSnapshot(): PluginAnimationManager.Snapshot {
-        if (!this.current) return { state: this.latestState };
+        if (!this.current) return { state: this.state };
 
         return {
-            state: this.latestState,
+            state: this.state,
             current: {
                 paramValues: this._current.paramValues,
                 state: this._current.anim.stateSerialization ? this._current.anim.stateSerialization.toJSON(this._current.state) : this._current.state
@@ -125,7 +152,7 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
                 ? this._current.anim.stateSerialization.fromJSON(snapshot.current.state)
                 : snapshot.current.state;
             this.triggerUpdate();
-            if (this.latestState.animationState === 'playing') this.resume();
+            if (this.state.animationState === 'playing') this.resume();
         }
     }
 
@@ -135,8 +162,8 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
         requestAnimationFrame(this.animate);
     }
 
-    constructor(ctx: PluginContext) {
-        super(ctx, { params: { current: '' }, animationState: 'stopped' });
+    constructor(private context: PluginContext) {
+        super({ params: { current: '' }, animationState: 'stopped' });
     }
 }
 
diff --git a/src/mol-plugin/state/animation/model.ts b/src/mol-plugin/state/animation/model.ts
index 82ba43ca9f3fccb271e97d59388221ea2f02d62b..88d99c653879f566900b18e04a639533d8e7c378 100644
--- a/src/mol-plugin/state/animation/model.ts
+++ b/src/mol-plugin/state/animation/model.ts
@@ -12,11 +12,11 @@ export { PluginStateAnimation }
 // TODO: helpers for building animations (once more animations are added)
 //       for example "composite animation"
 
-interface PluginStateAnimation<P extends PD.Params = any, S = any> {
+interface PluginStateAnimation<P = any, S = any> {
     name: string,
     readonly display: { readonly name: string, readonly description?: string },
-    params: (ctx: PluginContext) => P,
-    initialState(params: PD.Values<P>, ctx: PluginContext): S,
+    params: (ctx: PluginContext) => PD.For<P>,
+    initialState(params: P, ctx: PluginContext): S,
 
     /**
      * Apply the current frame and modify the state.
@@ -38,12 +38,12 @@ namespace PluginStateAnimation {
     }
 
     export type ApplyResult<S> = { kind: 'finished' } | { kind: 'skip' } | { kind: 'next', state: S }
-    export interface Context<P extends PD.Params> {
-        params: PD.Values<P>,
+    export interface Context<P> {
+        params: P,
         plugin: PluginContext
     }
 
-    export function create<P extends PD.Params, S>(params: PluginStateAnimation<P, S>) {
+    export function create<P, S>(params: PluginStateAnimation<P, S>) {
         return params;
     }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/state/camera.ts b/src/mol-plugin/state/camera.ts
index b05a2b0692879bd8a0636b44f4ab53d362e2eae3..830dbaf8de461f343944f16c1d1e1cfc21021579 100644
--- a/src/mol-plugin/state/camera.ts
+++ b/src/mol-plugin/state/camera.ts
@@ -7,57 +7,53 @@
 import { Camera } from 'mol-canvas3d/camera';
 import { OrderedMap } from 'immutable';
 import { UUID } from 'mol-util';
-import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { PluginComponent } from 'mol-plugin/component';
 
 export { CameraSnapshotManager }
 
-class CameraSnapshotManager {
-    private ev = RxEventHelper.create();
-    private _entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
-
+class CameraSnapshotManager extends PluginComponent<{ entries: OrderedMap<string, CameraSnapshotManager.Entry> }> {
     readonly events = {
         changed: this.ev()
     };
 
-    get entries() { return this._entries; }
-
     getEntry(id: string) {
-        return this._entries.get(id);
+        return this.state.entries.get(id);
     }
 
     remove(id: string) {
-        if (!this._entries.has(id)) return;
-        this._entries.delete(id);
+        if (!this.state.entries.has(id)) return;
+        this.updateState({ entries: this.state.entries.delete(id) });
         this.events.changed.next();
     }
 
     add(e: CameraSnapshotManager.Entry) {
-        this._entries.set(e.id, e);
+        this.updateState({ entries: this.state.entries.set(e.id, e) });
         this.events.changed.next();
     }
 
     clear() {
-        if (this._entries.size === 0) return;
-        this._entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
+        if (this.state.entries.size === 0) return;
+        this.updateState({ entries: OrderedMap<string, CameraSnapshotManager.Entry>() });
         this.events.changed.next();
     }
 
     getStateSnapshot(): CameraSnapshotManager.StateSnapshot {
         const entries: CameraSnapshotManager.Entry[] = [];
-        this._entries.forEach(e => entries.push(e!));
+        this.state.entries.forEach(e => entries.push(e!));
         return { entries };
     }
 
     setStateSnapshot(state: CameraSnapshotManager.StateSnapshot ) {
-        this._entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
+        const entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
         for (const e of state.entries) {
-            this._entries.set(e.id, e);
+            entries.set(e.id, e);
         }
+        this.updateState({ entries: entries.asImmutable() });
         this.events.changed.next();
     }
 
-    dispose() {
-        this.ev.dispose();
+    constructor() {
+        super({ entries: OrderedMap<string, CameraSnapshotManager.Entry>() });
     }
 }
 
diff --git a/src/mol-plugin/state/snapshots.ts b/src/mol-plugin/state/snapshots.ts
index 8d03ed5ae64a12902d462b635d2826350e5eae55..d9f4152d2f8ebd6a1e04916a0a498f6a0ee2d91f 100644
--- a/src/mol-plugin/state/snapshots.ts
+++ b/src/mol-plugin/state/snapshots.ts
@@ -6,44 +6,39 @@
 
 import { OrderedMap } from 'immutable';
 import { UUID } from 'mol-util';
-import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { PluginState } from '../state';
+import { PluginComponent } from 'mol-plugin/component';
 
 export { PluginStateSnapshotManager }
 
-class PluginStateSnapshotManager {
-    private ev = RxEventHelper.create();
-    private _entries = OrderedMap<string, PluginStateSnapshotManager.Entry>().asMutable();
-
+class PluginStateSnapshotManager extends PluginComponent<{ entries: OrderedMap<string, PluginStateSnapshotManager.Entry> }> {
     readonly events = {
         changed: this.ev()
     };
 
-    get entries() { return this._entries; }
-
     getEntry(id: string) {
-        return this._entries.get(id);
+        return this.state.entries.get(id);
     }
 
     remove(id: string) {
-        if (!this._entries.has(id)) return;
-        this._entries.delete(id);
+        if (!this.state.entries.has(id)) return;
+        this.updateState({ entries: this.state.entries.delete(id) });
         this.events.changed.next();
     }
 
     add(e: PluginStateSnapshotManager.Entry) {
-        this._entries.set(e.id, e);
+        this.updateState({ entries: this.state.entries.set(e.id, e) });
         this.events.changed.next();
     }
 
     clear() {
-        if (this._entries.size === 0) return;
-        this._entries = OrderedMap<string, PluginStateSnapshotManager.Entry>().asMutable();
+        if (this.state.entries.size === 0) return;
+        this.updateState({ entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() });
         this.events.changed.next();
     }
 
-    dispose() {
-        this.ev.dispose();
+    constructor() {
+        super({ entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() });
     }
 }
 
diff --git a/src/mol-plugin/state/transforms/model.ts b/src/mol-plugin/state/transforms/model.ts
index a28e72ada1d3fc3a30cddb12a12b8d493ca15bfd..4f67fc62cd221ab95984d506987628e2509057b3 100644
--- a/src/mol-plugin/state/transforms/model.ts
+++ b/src/mol-plugin/state/transforms/model.ts
@@ -24,6 +24,7 @@ import { volumeFromDensityServerData } from 'mol-model-formats/volume/density-se
 import { trajectoryFromMmCIF } from 'mol-model-formats/structure/mmcif';
 import { parsePDB } from 'mol-io/reader/pdb/parser';
 import { trajectoryFromPDB } from 'mol-model-formats/structure/pdb';
+import { Assembly } from 'mol-model/structure/model/properties/symmetry';
 
 export { TrajectoryFromMmCif }
 type TrajectoryFromMmCif = typeof TrajectoryFromMmCif
@@ -143,12 +144,26 @@ const StructureAssemblyFromModel = PluginStateTransform.BuiltIn({
         return Task.create('Build Assembly', async ctx => {
             const model = a.data;
             let id = params.id;
-            let asm = ModelSymmetry.findAssembly(model, id || '');
-            if (!!id && id !== 'deposited' && !asm) throw new Error(`Assembly '${id}' not found`);
+            let asm: Assembly | undefined = void 0;
+
+            // if no id is specified, use the 1st assembly.
+            if (!id && model.symmetry.assemblies.length !== 0) {
+                id = model.symmetry.assemblies[0].id;
+            }
+
+            if (model.symmetry.assemblies.length === 0) {
+                if (id !== 'deposited') {
+                    plugin.log.warn(`Model '${a.label}' has no assembly, returning deposited structure.`);
+                }
+            } else {
+                asm = ModelSymmetry.findAssembly(model, id || '');
+                if (!asm) {
+                    plugin.log.warn(`Model '${a.label}' has no assembly called '${id}', returning deposited structure.`);
+                }
+            }
 
             const base = Structure.ofModel(model);
-            if ((id && !asm) || model.symmetry.assemblies.length === 0) {
-                if (!!id && id !== 'deposited') plugin.log.warn(`Model '${a.label}' has no assembly, returning deposited structure.`);
+            if (!asm) {
                 const label = { label: a.data.label, description: structureDesc(base) };
                 return new SO.Molecule.Structure(base, label);
             }
diff --git a/src/mol-plugin/ui/base.tsx b/src/mol-plugin/ui/base.tsx
index db43db77315ab37271bdd5b436115d5176272961..d4ae0171d84882a5f0c9fb6e2152b4fbfd5e2d83 100644
--- a/src/mol-plugin/ui/base.tsx
+++ b/src/mol-plugin/ui/base.tsx
@@ -10,7 +10,7 @@ import { PluginContext } from '../context';
 
 export const PluginReactContext = React.createContext(void 0 as any as PluginContext);
 
-export abstract class PluginComponent<P = {}, S = {}, SS = {}> extends React.Component<P, S, SS> {
+export abstract class PluginUIComponent<P = {}, S = {}, SS = {}> extends React.Component<P, S, SS> {
     static contextType = PluginReactContext;
     readonly plugin: PluginContext;
 
@@ -35,7 +35,7 @@ export abstract class PluginComponent<P = {}, S = {}, SS = {}> extends React.Com
     }
 }
 
-export abstract class PurePluginComponent<P = {}, S = {}, SS = {}> extends React.PureComponent<P, S, SS> {
+export abstract class PurePluginUIComponent<P = {}, S = {}, SS = {}> extends React.PureComponent<P, S, SS> {
     static contextType = PluginReactContext;
     readonly plugin: PluginContext;
 
diff --git a/src/mol-plugin/ui/camera.tsx b/src/mol-plugin/ui/camera.tsx
index ddcf5995d5fff6c62de49ea2e04d0f150da79a69..796bc5d869d85681a4af84d8f6711ec2cc3033bc 100644
--- a/src/mol-plugin/ui/camera.tsx
+++ b/src/mol-plugin/ui/camera.tsx
@@ -6,11 +6,11 @@
 
 import { PluginCommands } from 'mol-plugin/command';
 import * as React from 'react';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { ParameterControls } from './controls/parameters';
 
-export class CameraSnapshots extends PluginComponent<{ }, { }> {
+export class CameraSnapshots extends PluginUIComponent<{ }, { }> {
     render() {
         return <div>
             <div className='msp-section-header'>Camera Snapshots</div>
@@ -20,7 +20,7 @@ export class CameraSnapshots extends PluginComponent<{ }, { }> {
     }
 }
 
-class CameraSnapshotControls extends PluginComponent<{ }, { name: string, description: string }> {
+class CameraSnapshotControls extends PluginUIComponent<{ }, { name: string, description: string }> {
     static Params = {
         name: PD.Text(),
         description: PD.Text()
@@ -48,7 +48,7 @@ class CameraSnapshotControls extends PluginComponent<{ }, { name: string, descri
     }
 }
 
-class CameraSnapshotList extends PluginComponent<{ }, { }> {
+class CameraSnapshotList extends PluginUIComponent<{ }, { }> {
     componentDidMount() {
         this.subscribe(this.plugin.events.state.cameraSnapshots.changed, () => this.forceUpdate());
     }
@@ -65,7 +65,7 @@ class CameraSnapshotList extends PluginComponent<{ }, { }> {
 
     render() {
         return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
-            {this.plugin.state.cameraSnapshots.entries.valueSeq().map(e =><li key={e!.id}>
+            {this.plugin.state.cameraSnapshots.state.entries.valueSeq().map(e =><li key={e!.id}>
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.id)}>{e!.name || e!.timestamp} <small>{e!.description}</small></button>
                 <button onClick={this.remove(e!.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
                     <span className='msp-icon msp-icon-remove' />
diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx
index 2a29958116c3495a7dc844ea0dd2e10f4643761a..db69594a3c13abea794a75df957abdd9dad58e19 100644
--- a/src/mol-plugin/ui/controls.tsx
+++ b/src/mol-plugin/ui/controls.tsx
@@ -7,10 +7,10 @@
 import * as React from 'react';
 import { PluginCommands } from 'mol-plugin/command';
 import { UpdateTrajectory } from 'mol-plugin/state/actions/basic';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 import { LociLabelEntry } from 'mol-plugin/util/loci-label-manager';
 
-export class Controls extends PluginComponent<{ }, { }> {
+export class Controls extends PluginUIComponent<{ }, { }> {
     render() {
         return <>
 
@@ -18,7 +18,7 @@ export class Controls extends PluginComponent<{ }, { }> {
     }
 }
 
-export class TrajectoryControls extends PluginComponent {
+export class TrajectoryControls extends PluginUIComponent {
     render() {
         return <div>
             <button className='msp-btn msp-btn-link' onClick={() => PluginCommands.State.ApplyAction.dispatch(this.plugin, {
@@ -37,7 +37,7 @@ export class TrajectoryControls extends PluginComponent {
     }
 }
 
-export class LociLabelControl extends PluginComponent<{}, { entries: ReadonlyArray<LociLabelEntry> }> {
+export class LociLabelControl extends PluginUIComponent<{}, { entries: ReadonlyArray<LociLabelEntry> }> {
     state = { entries: [] }
 
     componentDidMount() {
diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx
index ad7ef082d48cf230cbe745d3e4bed7a610d6b545..868c44f16e20e6ae927fe732b197831b73504546 100644
--- a/src/mol-plugin/ui/controls/parameters.tsx
+++ b/src/mol-plugin/ui/controls/parameters.tsx
@@ -29,8 +29,10 @@ export class ParameterControls<P extends PD.Params> extends React.PureComponent<
     render() {
         const params = this.props.params;
         const values = this.props.values;
+        const keys = Object.keys(params);
+        if (keys.length === 0) return null;
         return <div style={{ width: '100%' }}>
-            {Object.keys(params).map(key => {
+            {keys.map(key => {
                 const param = params[key];
                 if (param.isHidden) return null;
                 const Control = controlFor(param);
@@ -434,6 +436,10 @@ export class GroupControl extends React.PureComponent<ParamProps<PD.Group<any>>,
 
     render() {
         const params = this.props.param.params;
+
+        // Do not show if there are no params.
+        if (Object.keys(params).length === 0) return null;
+
         const label = this.props.param.label || camelCaseToWords(this.props.name);
 
         const controls = <ParameterControls params={params} onChange={this.onChangeParam} values={this.props.value} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />;
diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx
index e465a67a623ddfb0cf563a02590ad1bcbbeb53c5..e0ee8590fa071f3d79fbd924b49c8bb4d3931282 100644
--- a/src/mol-plugin/ui/plugin.tsx
+++ b/src/mol-plugin/ui/plugin.tsx
@@ -9,7 +9,7 @@ import { PluginContext } from '../context';
 import { StateTree } from './state-tree';
 import { Viewport, ViewportControls } from './viewport';
 import { Controls, TrajectoryControls, LociLabelControl } from './controls';
-import { PluginComponent, PluginReactContext } from './base';
+import { PluginUIComponent, PluginReactContext } from './base';
 import { CameraSnapshots } from './camera';
 import { StateSnapshots } from './state';
 import { List } from 'immutable';
@@ -39,9 +39,9 @@ export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
     }
 }
 
-class Layout extends PluginComponent {
+class Layout extends PluginUIComponent {
     componentDidMount() {
-        this.subscribe(this.plugin.layout.updated, () => this.forceUpdate());
+        this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
     }
 
     region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) {
@@ -53,7 +53,7 @@ class Layout extends PluginComponent {
     }
 
     render() {
-        const layout = this.plugin.layout.latestState;
+        const layout = this.plugin.layout.state;
         return <div className='msp-plugin'>
             <div className={`msp-plugin-content ${layout.isExpanded ? 'msp-layout-expanded' : 'msp-layout-standard msp-layout-standard-outside'}`}>
                 <div className={layout.showControls ? 'msp-layout-hide-top' : 'msp-layout-hide-top msp-layout-hide-right msp-layout-hide-bottom msp-layout-hide-left'}>
@@ -73,7 +73,7 @@ class Layout extends PluginComponent {
     }
 }
 
-export class ViewportWrapper extends PluginComponent {
+export class ViewportWrapper extends PluginUIComponent {
     render() {
         return <>
             <Viewport />
@@ -91,7 +91,7 @@ export class ViewportWrapper extends PluginComponent {
     }
 }
 
-export class State extends PluginComponent {
+export class State extends PluginUIComponent {
     componentDidMount() {
         this.subscribe(this.plugin.state.behavior.kind, () => this.forceUpdate());
     }
@@ -113,18 +113,18 @@ export class State extends PluginComponent {
     }
 }
 
-export class Log extends PluginComponent<{}, { entries: List<LogEntry> }> {
+export class Log extends PluginUIComponent<{}, { entries: List<LogEntry> }> {
     private wrapper = React.createRef<HTMLDivElement>();
 
     componentDidMount() {
-        this.subscribe(this.plugin.events.log, () => this.setState({ entries: this.plugin.log.entries.takeLast(100).toList() }));
+        this.subscribe(this.plugin.events.log, () => this.setState({ entries: this.plugin.log.entries }));
     }
 
     componentDidUpdate() {
         this.scrollToBottom();
     }
 
-    state = { entries: this.plugin.log.entries.takeLast(100).toList() };
+    state = { entries: this.plugin.log.entries };
 
     private scrollToBottom() {
         const log = this.wrapper.current;
@@ -132,19 +132,26 @@ export class Log extends PluginComponent<{}, { entries: List<LogEntry> }> {
     }
 
     render() {
+        // TODO: ability to show full log
+        // showing more entries dramatically slows animations.
+        const maxEntries = 10;
+        const xs = this.state.entries, l = xs.size;
+        const entries: JSX.Element[] = [];
+        for (let i = Math.max(0, l - maxEntries), o = 0; i < l; i++) {
+            const e = xs.get(i);
+            entries.push(<li key={o++}>
+                <div className={'msp-log-entry-badge msp-log-entry-' + e!.type} />
+                <div className='msp-log-timestamp'>{formatTime(e!.timestamp)}</div>
+                <div className='msp-log-entry'>{e!.message}</div>
+            </li>);
+        }
         return <div ref={this.wrapper} className='msp-log' style={{ position: 'absolute', top: '0', right: '0', bottom: '0', left: '0', overflowY: 'auto' }}>
-            <ul className='msp-list-unstyled'>
-                {this.state.entries.map((e, i) => <li key={i}>
-                    <div className={'msp-log-entry-badge msp-log-entry-' + e!.type} />
-                    <div className='msp-log-timestamp'>{formatTime(e!.timestamp)}</div>
-                    <div className='msp-log-entry'>{e!.message}</div>
-                </li>)}
-            </ul>
+            <ul className='msp-list-unstyled'>{entries}</ul>
         </div>;
     }
 }
 
-export class CurrentObject extends PluginComponent {
+export class CurrentObject extends PluginUIComponent {
     get current() {
         return this.plugin.state.behavior.currentObject.value;
     }
diff --git a/src/mol-plugin/ui/state-tree.tsx b/src/mol-plugin/ui/state-tree.tsx
index 59a5fc8c260476607d79dfe8a2bac39fd5ff1596..d91238056a0714691a169533c87f10c86155aa65 100644
--- a/src/mol-plugin/ui/state-tree.tsx
+++ b/src/mol-plugin/ui/state-tree.tsx
@@ -8,16 +8,16 @@ import * as React from 'react';
 import { PluginStateObject } from 'mol-plugin/state/objects';
 import { State, StateObject } from 'mol-state'
 import { PluginCommands } from 'mol-plugin/command';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 
-export class StateTree extends PluginComponent<{ state: State }> {
+export class StateTree extends PluginUIComponent<{ state: State }> {
     render() {
         const n = this.props.state.tree.root.ref;
         return <StateTreeNode state={this.props.state} nodeRef={n} />;
     }
 }
 
-class StateTreeNode extends PluginComponent<{ nodeRef: string, state: State }, { state: State, isCollapsed: boolean }> {
+class StateTreeNode extends PluginUIComponent<{ nodeRef: string, state: State }, { state: State, isCollapsed: boolean }> {
     is(e: State.ObjectEvent) {
         return e.ref === this.props.nodeRef && e.state === this.props.state;
     }
@@ -77,7 +77,7 @@ class StateTreeNode extends PluginComponent<{ nodeRef: string, state: State }, {
     }
 }
 
-class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State }, { state: State, isCurrent: boolean, isCollapsed: boolean }> {
+class StateTreeNodeLabel extends PluginUIComponent<{ nodeRef: string, state: State }, { state: State, isCurrent: boolean, isCollapsed: boolean }> {
     is(e: State.ObjectEvent) {
         return e.ref === this.props.nodeRef && e.state === this.props.state;
     }
diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx
index b9a16ad129c0e3dd4fc737170b9675fe757ba850..6e13eb42e85f0cd17e167e54713ea618840bee76 100644
--- a/src/mol-plugin/ui/state.tsx
+++ b/src/mol-plugin/ui/state.tsx
@@ -6,14 +6,14 @@
 
 import { PluginCommands } from 'mol-plugin/command';
 import * as React from 'react';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 import { shallowEqual } from 'mol-util';
 import { List } from 'immutable';
 import { ParameterControls } from './controls/parameters';
 import { ParamDefinition as PD} from 'mol-util/param-definition';
 import { Subject } from 'rxjs';
 
-export class StateSnapshots extends PluginComponent<{ }, { serverUrl: string }> {
+export class StateSnapshots extends PluginUIComponent<{ }, { serverUrl: string }> {
     state = { serverUrl: 'https://webchem.ncbr.muni.cz/molstar-state' }
 
     updateServerUrl = (serverUrl: string) => { this.setState({ serverUrl }) };
@@ -31,7 +31,7 @@ export class StateSnapshots extends PluginComponent<{ }, { serverUrl: string }>
 // TODO: this is not nice: device some custom event system.
 const UploadedEvent = new Subject();
 
-class StateSnapshotControls extends PluginComponent<{ serverUrl: string, serverChanged: (url: string) => void }, { name: string, description: string, serverUrl: string, isUploading: boolean }> {
+class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serverChanged: (url: string) => void }, { name: string, description: string, serverUrl: string, isUploading: boolean }> {
     state = { name: '', description: '', serverUrl: this.props.serverUrl, isUploading: false };
 
     static Params = {
@@ -93,7 +93,7 @@ class StateSnapshotControls extends PluginComponent<{ serverUrl: string, serverC
     }
 }
 
-class LocalStateSnapshotList extends PluginComponent<{ }, { }> {
+class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
     componentDidMount() {
         this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
     }
@@ -110,7 +110,7 @@ class LocalStateSnapshotList extends PluginComponent<{ }, { }> {
 
     render() {
         return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
-            {this.plugin.state.snapshots.entries.valueSeq().map(e =><li key={e!.id}>
+            {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.id}>
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.id)}>{e!.name || e!.timestamp} <small>{e!.description}</small></button>
                 <button onClick={this.remove(e!.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
                     <span className='msp-icon msp-icon-remove' />
@@ -121,7 +121,7 @@ class LocalStateSnapshotList extends PluginComponent<{ }, { }> {
 }
 
 type RemoteEntry = { url: string, removeUrl: string, timestamp: number, id: string, name: string, description: string }
-class RemoteStateSnapshotList extends PluginComponent<{ serverUrl: string }, { entries: List<RemoteEntry>, isFetching: boolean }> {
+class RemoteStateSnapshotList extends PluginUIComponent<{ serverUrl: string }, { entries: List<RemoteEntry>, isFetching: boolean }> {
     state = { entries: List<RemoteEntry>(), isFetching: false };
 
     componentDidMount() {
diff --git a/src/mol-plugin/ui/state/animation.tsx b/src/mol-plugin/ui/state/animation.tsx
index c25fc4edaa18045c164b7eb0395a23912a000b8d..64ebcc6c01f8474456d893097042057ca951cc17 100644
--- a/src/mol-plugin/ui/state/animation.tsx
+++ b/src/mol-plugin/ui/state/animation.tsx
@@ -5,12 +5,12 @@
  */
 
 import * as React from 'react';
-import { PluginComponent } from '../base';
+import { PluginUIComponent } from '../base';
 import { ParameterControls, ParamOnChange } from '../controls/parameters';
 
-export class AnimationControls extends PluginComponent<{ }> {
+export class AnimationControls extends PluginUIComponent<{ }> {
     componentDidMount() {
-        this.subscribe(this.plugin.state.animation.updated, () => this.forceUpdate());
+        this.subscribe(this.plugin.state.animation.events.updated, () => this.forceUpdate());
     }
 
     updateParams: ParamOnChange = p => {
@@ -23,7 +23,7 @@ export class AnimationControls extends PluginComponent<{ }> {
 
     startOrStop = () => {
         const anim = this.plugin.state.animation;
-        if (anim.latestState.animationState === 'playing') anim.stop();
+        if (anim.state.animationState === 'playing') anim.stop();
         else anim.start();
     }
 
@@ -31,17 +31,17 @@ export class AnimationControls extends PluginComponent<{ }> {
         const anim = this.plugin.state.animation;
         if (anim.isEmpty) return null;
 
-        const isDisabled = anim.latestState.animationState === 'playing';
+        const isDisabled = anim.state.animationState === 'playing';
 
         return <div className='msp-animation-section'>
             <div className='msp-section-header'>Animations</div>
 
-            <ParameterControls params={anim.getParams()} values={anim.latestState.params} onChange={this.updateParams} isDisabled={isDisabled} />
+            <ParameterControls params={anim.getParams()} values={anim.state.params} onChange={this.updateParams} isDisabled={isDisabled} />
             <ParameterControls params={anim.current.params} values={anim.current.paramValues} onChange={this.updateCurrentParams} isDisabled={isDisabled} />
 
             <div className='msp-btn-row-group'>
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={this.startOrStop}>
-                    {anim.latestState.animationState === 'playing' ? 'Stop' : 'Start'}
+                    {anim.state.animationState === 'playing' ? 'Stop' : 'Start'}
                 </button>
             </div>
         </div>
diff --git a/src/mol-plugin/ui/state/common.tsx b/src/mol-plugin/ui/state/common.tsx
index daf5f2568aeca146e4cbf638d614a67ddc2906cf..b6570cbafc1cff2fbbe78c3fc2b7a3b9003a5e90 100644
--- a/src/mol-plugin/ui/state/common.tsx
+++ b/src/mol-plugin/ui/state/common.tsx
@@ -6,7 +6,7 @@
 
 import { State, Transform, Transformer } from 'mol-state';
 import * as React from 'react';
-import { PurePluginComponent } from '../base';
+import { PurePluginUIComponent } from '../base';
 import { ParameterControls, ParamOnChange } from '../controls/parameters';
 import { StateAction } from 'mol-state/action';
 import { PluginContext } from 'mol-plugin/context';
@@ -15,7 +15,7 @@ import { Subject } from 'rxjs';
 
 export { StateTransformParameters, TransformContolBase };
 
-class StateTransformParameters extends PurePluginComponent<StateTransformParameters.Props> {
+class StateTransformParameters extends PurePluginUIComponent<StateTransformParameters.Props> {
     validate(params: any) {
         // TODO
         return void 0;
@@ -97,7 +97,7 @@ namespace TransformContolBase {
     }
 }
 
-abstract class TransformContolBase<P, S extends TransformContolBase.ControlState> extends PurePluginComponent<P, S> {
+abstract class TransformContolBase<P, S extends TransformContolBase.ControlState> extends PurePluginUIComponent<P, S> {
     abstract applyAction(): Promise<void>;
     abstract getInfo(): StateTransformParameters.Props['info'];
     abstract getHeader(): Transformer.Definition['display'];
diff --git a/src/mol-plugin/ui/task.tsx b/src/mol-plugin/ui/task.tsx
index c45b783ea05824070df202b5e24712b37cf61c09..20b1c7f14a072a0276ef709d4893c9f0785e8f8f 100644
--- a/src/mol-plugin/ui/task.tsx
+++ b/src/mol-plugin/ui/task.tsx
@@ -5,13 +5,13 @@
  */
 
 import * as React from 'react';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 import { OrderedMap } from 'immutable';
 import { TaskManager } from 'mol-plugin/util/task-manager';
 import { filter } from 'rxjs/operators';
 import { Progress } from 'mol-task';
 
-export class BackgroundTaskProgress extends PluginComponent<{ }, { tracked: OrderedMap<number, TaskManager.ProgressEvent> }> {
+export class BackgroundTaskProgress extends PluginUIComponent<{ }, { tracked: OrderedMap<number, TaskManager.ProgressEvent> }> {
     componentDidMount() {
         this.subscribe(this.plugin.events.task.progress.pipe(filter(e => e.level !== 'none')), e => {
             this.setState({ tracked: this.state.tracked.set(e.id, e) })
@@ -30,7 +30,7 @@ export class BackgroundTaskProgress extends PluginComponent<{ }, { tracked: Orde
     }
 }
 
-class ProgressEntry extends PluginComponent<{ event: TaskManager.ProgressEvent }> {
+class ProgressEntry extends PluginUIComponent<{ event: TaskManager.ProgressEvent }> {
     render() {
         const root = this.props.event.progress.root;
         const subtaskCount = countSubtasks(this.props.event.progress.root) - 1;
diff --git a/src/mol-plugin/ui/viewport.tsx b/src/mol-plugin/ui/viewport.tsx
index b23bf8977b3a8aa7e33481ba23ae61b17863d00b..ce917a04dbbaa269c33d1c9115bdec8f818f5baf 100644
--- a/src/mol-plugin/ui/viewport.tsx
+++ b/src/mol-plugin/ui/viewport.tsx
@@ -8,7 +8,7 @@
 import * as React from 'react';
 import { ButtonsType } from 'mol-util/input/input-observer';
 import { Canvas3dIdentifyHelper } from 'mol-plugin/util/canvas3d-identify';
-import { PluginComponent } from './base';
+import { PluginUIComponent } from './base';
 import { PluginCommands } from 'mol-plugin/command';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { ParameterControls } from './controls/parameters';
@@ -20,7 +20,7 @@ interface ViewportState {
     noWebGl: boolean
 }
 
-export class ViewportControls extends PluginComponent {
+export class ViewportControls extends PluginUIComponent {
     state = {
         isSettingsExpanded: false
     }
@@ -35,11 +35,11 @@ export class ViewportControls extends PluginComponent {
     }
 
     toggleControls = () => {
-        PluginCommands.Layout.Update.dispatch(this.plugin, { state: { showControls: !this.plugin.layout.latestState.showControls } });
+        PluginCommands.Layout.Update.dispatch(this.plugin, { state: { showControls: !this.plugin.layout.state.showControls } });
     }
 
     toggleExpanded = () => {
-        PluginCommands.Layout.Update.dispatch(this.plugin, { state: { isExpanded: !this.plugin.layout.latestState.isExpanded } });
+        PluginCommands.Layout.Update.dispatch(this.plugin, { state: { isExpanded: !this.plugin.layout.state.isExpanded } });
     }
 
     setSettings = (p: { param: PD.Base<any>, name: string, value: any }) => {
@@ -55,7 +55,7 @@ export class ViewportControls extends PluginComponent {
             this.forceUpdate();
         });
 
-        this.subscribe(this.plugin.layout.updated, () => {
+        this.subscribe(this.plugin.layout.events.updated, () => {
             this.forceUpdate();
         });
     }
@@ -73,15 +73,15 @@ export class ViewportControls extends PluginComponent {
         // TODO: show some icons dimmed etc..
         return <div className={'msp-viewport-controls'}>
             <div className='msp-viewport-controls-buttons'>
-                {this.icon('tools', this.toggleControls, 'Toggle Controls', this.plugin.layout.latestState.showControls)}
-                {this.icon('expand-layout', this.toggleExpanded, 'Toggle Expanded', this.plugin.layout.latestState.isExpanded)}
+                {this.icon('tools', this.toggleControls, 'Toggle Controls', this.plugin.layout.state.showControls)}
+                {this.icon('expand-layout', this.toggleExpanded, 'Toggle Expanded', this.plugin.layout.state.isExpanded)}
                 {this.icon('settings', this.toggleSettingsExpanded, 'Settings', this.state.isSettingsExpanded)}
                 {this.icon('reset-scene', this.resetCamera, 'Reset Camera')}
             </div>
             {this.state.isSettingsExpanded &&
             <div className='msp-viewport-controls-scene-options'>
                 <ControlGroup header='Layout' initialExpanded={true}>
-                    <ParameterControls params={PluginLayoutStateParams} values={this.plugin.layout.latestState} onChange={this.setLayout} />
+                    <ParameterControls params={PluginLayoutStateParams} values={this.plugin.layout.state} onChange={this.setLayout} />
                 </ControlGroup>
                 <ControlGroup header='Viewport' initialExpanded={true}>
                     <ParameterControls params={Canvas3DParams} values={this.plugin.canvas3d.props} onChange={this.setSettings} />
@@ -91,7 +91,7 @@ export class ViewportControls extends PluginComponent {
     }
 }
 
-export class Viewport extends PluginComponent<{ }, ViewportState> {
+export class Viewport extends PluginUIComponent<{ }, ViewportState> {
     private container = React.createRef<HTMLDivElement>();
     private canvas = React.createRef<HTMLCanvasElement>();
 
@@ -128,7 +128,7 @@ export class Viewport extends PluginComponent<{ }, ViewportState> {
             idHelper.select(x, y);
         });
 
-        this.subscribe(this.plugin.layout.updated, () => {
+        this.subscribe(this.plugin.layout.events.updated, () => {
             setTimeout(this.handleResize, 50);
         });
     }
diff --git a/src/mol-plugin/util/canvas3d-identify.ts b/src/mol-plugin/util/canvas3d-identify.ts
index 74562d92b5c968166cd0f2346af1d2ef92c0ef55..0a54889f02385057cb7fdc581b5e7f39e48bfdeb 100644
--- a/src/mol-plugin/util/canvas3d-identify.ts
+++ b/src/mol-plugin/util/canvas3d-identify.ts
@@ -52,7 +52,7 @@ export class Canvas3dIdentifyHelper {
     }
 
     private animate: (t: number) => void = t => {
-        if (this.inside && t - this.prevT > 1000 / this.maxFps) {
+        if (!this.ctx.state.animation.isAnimating && this.inside && t - this.prevT > 1000 / this.maxFps) {
             this.prevT = t;
             this.currentIdentifyT = t;
             this.identify(false, t);
diff --git a/src/mol-state/object.ts b/src/mol-state/object.ts
index e8bdf73751d8d4b7fe63d5cffb7c6a834ba4204a..857d3249f5ea0cda44ad9f39f4d1cbf63e014d53 100644
--- a/src/mol-state/object.ts
+++ b/src/mol-state/object.ts
@@ -59,7 +59,6 @@ interface StateObjectCell<T = StateObject> {
     // Which object was used as a parent to create data in this cell
     sourceRef: Transform.Ref | undefined,
 
-    version: string
     status: StateObjectCell.Status,
 
     params: {
diff --git a/src/mol-state/state.ts b/src/mol-state/state.ts
index 42030da32278c8e08da5aefae600c17bf1d455ea..766daa3fb470882e8c853923eb25567fa2f9ce6f 100644
--- a/src/mol-state/state.ts
+++ b/src/mol-state/state.ts
@@ -8,7 +8,6 @@ import { StateObject, StateObjectCell } from './object';
 import { StateTree } from './tree';
 import { Transform } from './transform';
 import { Transformer } from './transformer';
-import { UUID } from 'mol-util';
 import { RuntimeContext, Task } from 'mol-task';
 import { StateSelection } from './state/selection';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
@@ -184,7 +183,6 @@ class State {
             sourceRef: void 0,
             obj: rootObject,
             status: 'ok',
-            version: root.version,
             errorText: void 0,
             params: {
                 definition: {},
@@ -350,7 +348,7 @@ function findUpdateRoots(cells: Map<Transform.Ref, StateObjectCell>, tree: State
 
 function findUpdateRootsVisitor(n: Transform, _: any, s: { roots: Ref[], cells: Map<Ref, StateObjectCell> }) {
     const cell = s.cells.get(n.ref);
-    if (!cell || cell.version !== n.version || cell.status === 'error') {
+    if (!cell || cell.transform.version !== n.version || cell.status === 'error') {
         s.roots.push(n.ref);
         return false;
     }
@@ -406,7 +404,6 @@ function initCellsVisitor(transform: Transform, _: any, { ctx, added }: InitCell
         transform,
         sourceRef: void 0,
         status: 'pending',
-        version: UUID.create22(),
         errorText: void 0,
         params: void 0
     };
@@ -556,7 +553,6 @@ async function updateNode(ctx: UpdateContext, currentRef: Ref): Promise<UpdateNo
 
     // special case for Root
     if (current.transform.ref === Transform.RootRef) {
-        current.version = transform.version;
         return { action: 'none' };
     }
 
@@ -574,7 +570,6 @@ async function updateNode(ctx: UpdateContext, currentRef: Ref): Promise<UpdateNo
         current.params = params;
         const obj = await createObject(ctx, currentRef, transform.transformer, parent, params.values);
         current.obj = obj;
-        current.version = transform.version;
 
         return { ref: currentRef, action: 'created', obj };
     } else {
@@ -591,14 +586,11 @@ async function updateNode(ctx: UpdateContext, currentRef: Ref): Promise<UpdateNo
                 const oldObj = current.obj;
                 const newObj = await createObject(ctx, currentRef, transform.transformer, parent, newParams);
                 current.obj = newObj;
-                current.version = transform.version;
                 return { ref: currentRef, action: 'replaced', oldObj, obj: newObj };
             }
             case Transformer.UpdateResult.Updated:
-                current.version = transform.version;
                 return { ref: currentRef, action: 'updated', obj: current.obj! };
             default:
-                current.version = transform.version;
                 return { action: 'none' };
         }
     }
diff --git a/src/mol-state/state/selection.ts b/src/mol-state/state/selection.ts
index 828f7c9cf85a0926dae10d13bc6c675094939b64..d9aec37f87f11cff889c322832dd020e0c3b16dc 100644
--- a/src/mol-state/state/selection.ts
+++ b/src/mol-state/state/selection.ts
@@ -29,7 +29,7 @@ namespace StateSelection {
     }
 
     function isObj(arg: any): arg is StateObjectCell {
-        return (arg as StateObjectCell).version !== void 0 && (arg as StateObjectCell).transform !== void 0;
+        return (arg as StateObjectCell).transform !== void 0 && (arg as StateObjectCell).status !== void 0;
     }
 
     function isBuilder(arg: any): arg is Builder {
diff --git a/webpack.config.js b/webpack.config.js
index a25ec62f361996fd8f2030ee13133d11d0227d44..7abfd52d3558fe5f8dab53a9dfddd5104d4d807e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -49,6 +49,8 @@ const sharedConfig = {
         }),
         new webpack.DefinePlugin({
             __PLUGIN_VERSION_TIMESTAMP__: webpack.DefinePlugin.runtimeValue(() => `${new Date().valueOf()}`, true),
+            // include this for production version of React
+            // 'process.env.NODE_ENV': JSON.stringify('production')
         }),
         new MiniCssExtractPlugin({ filename: 'app.css' })
     ],