diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index c59f2cf6c7d1f739351734f9e300c144ced9e9c9..b3225a1f2aa3536b48e7f68f44220d42958af6eb 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -9,11 +9,6 @@
             "problemMatcher": [
                 "$tsc"
             ]
-        },
-        {
-            "type": "npm",
-            "script": "app-render-test",
-            "problemMatcher": []
         }
     ]
 }
\ No newline at end of file
diff --git a/docs/state/readme.md b/docs/state/readme.md
index bc20ea3df433e837a729edb60fe5da749ceef05b..22ae1ccc62d1f76e148bfbf672ac0b0ca2365d23 100644
--- a/docs/state/readme.md
+++ b/docs/state/readme.md
@@ -8,6 +8,8 @@ interface Snapshot {
     data?: State.Snapshot,
     // Snapshot of behavior state tree
     behaviour?: State.Snapshot,
+    // Snapshot for current animation,
+    animation?: PluginAnimationManager.Snapshot,
     // Saved camera positions
     cameraSnapshots?: CameraSnapshotManager.StateSnapshot,
     canvas3d?: {
@@ -69,6 +71,10 @@ interface Transform.Props {
 
 "Built-in" data state transforms and description of their parameters are defined in ``mol-plugin/state/transforms``. Behavior transforms are defined in ``mol-plugin/behavior``. Auto-generated documentation for the transforms is also [available](transforms.md).
 
+# Animation State
+
+Defined by ``CameraSnapshotManager.StateSnapshot`` in ``mol-plugin/state/animation/manager.ts``.
+
 # Canvas3D State
 
 Defined by ``Canvas3DParams`` in ``mol-canvas3d/canvas3d.ts``.
diff --git a/docs/state/transforms.md b/docs/state/transforms.md
index f02e38b687426456cbaf3738956c41d75058e835..473030fade96825b498d9dc66bb053382da39c1d 100644
--- a/docs/state/transforms.md
+++ b/docs/state/transforms.md
@@ -7,9 +7,11 @@
 * [ms-plugin.parse-ccp4](#ms-plugin-parse-ccp4)
 * [ms-plugin.parse-dsn6](#ms-plugin-parse-dsn6)
 * [ms-plugin.trajectory-from-mmcif](#ms-plugin-trajectory-from-mmcif)
+* [ms-plugin.trajectory-from-pdb](#ms-plugin-trajectory-from-pdb)
 * [ms-plugin.model-from-trajectory](#ms-plugin-model-from-trajectory)
 * [ms-plugin.structure-from-model](#ms-plugin-structure-from-model)
 * [ms-plugin.structure-assembly-from-model](#ms-plugin-structure-assembly-from-model)
+* [ms-plugin.structure-symmetry-from-model](#ms-plugin-structure-symmetry-from-model)
 * [ms-plugin.structure-selection](#ms-plugin-structure-selection)
 * [ms-plugin.structure-complex-element](#ms-plugin-structure-complex-element)
 * [ms-plugin.custom-model-properties](#ms-plugin-custom-model-properties)
@@ -65,7 +67,7 @@
 
 ----------------------------
 ## <a name="ms-plugin-parse-ccp4"></a>ms-plugin.parse-ccp4 :: Binary -> Ccp4
-*Parse CCP4/MRC from Binary data*
+*Parse CCP4/MRC/MAP from Binary data*
 
 ----------------------------
 ## <a name="ms-plugin-parse-dsn6"></a>ms-plugin.parse-dsn6 :: Binary -> Dsn6
@@ -82,6 +84,9 @@
 ```js
 {}
 ```
+----------------------------
+## <a name="ms-plugin-trajectory-from-pdb"></a>ms-plugin.trajectory-from-pdb :: String -> Trajectory
+
 ----------------------------
 ## <a name="ms-plugin-model-from-trajectory"></a>ms-plugin.model-from-trajectory :: Trajectory -> Model
 *Create a molecular structure from the specified model.*
@@ -104,13 +109,36 @@
 *Create a molecular structure assembly.*
 
 ### Parameters
-- **id**?: String *(Assembly Id. If none specified (undefined or empty string), the asymmetric unit is used.)*
+- **id**?: String *(Assembly Id. Value 'deposited' can be used to specify deposited asymmetric unit.)*
 
 ### Default Parameters
 ```js
 {}
 ```
 ----------------------------
+## <a name="ms-plugin-structure-symmetry-from-model"></a>ms-plugin.structure-symmetry-from-model :: Model -> Structure
+*Create a molecular structure symmetry.*
+
+### Parameters
+- **ijkMin**: 3D vector [x, y, z]
+- **ijkMax**: 3D vector [x, y, z]
+
+### Default Parameters
+```js
+{
+  "ijkMin": [
+    -1,
+    -1,
+    -1
+  ],
+  "ijkMax": [
+    1,
+    1,
+    1
+  ]
+}
+```
+----------------------------
 ## <a name="ms-plugin-structure-selection"></a>ms-plugin.structure-selection :: Structure -> Structure
 *Create a molecular structure from the specified query expression.*
 
@@ -149,7 +177,7 @@
 ```
 ----------------------------
 ## <a name="ms-plugin-volume-from-ccp4"></a>ms-plugin.volume-from-ccp4 :: Ccp4 -> Data
-*Create Volume from CCP4/MRC data*
+*Create Volume from CCP4/MRC/MAP data*
 
 ### Parameters
 - **voxelSize**: 3D vector [x, y, z]
@@ -294,7 +322,7 @@ Object with:
       - **highlightColor**: Color as 0xrrggbb
       - **selectColor**: Color as 0xrrggbb
       - **quality**: One of 'custom', 'auto', 'highest', 'higher', 'high', 'medium', 'low', 'lower', 'lowest'
-      - **isoValue**: Numeric value
+      - **isoValueNorm**: Numeric value *(Normalized Isolevel Value)*
       - **renderMode**: One of 'isosurface', 'volume'
       - **controlPoints**: A list of 2d vectors [xi, yi][]
       - **list**: One of 'OrangeRed', 'PurpleBlue', 'BluePurple', 'Oranges', 'BlueGreen', 'YellowOrangeBrown', 'YellowGreen', 'Reds', 'RedPurple', 'Greens', 'YellowGreenBlue', 'Purples', 'GreenBlue', 'Greys', 'YellowOrangeRed', 'PurpleRed', 'Blues', 'PurpleBlueGreen', 'Spectral', 'RedYellowGreen', 'RedBlue', 'PinkYellowGreen', 'PurpleGreen', 'RedYellowBlue', 'BrownWhiteGreen', 'RedGrey', 'PurpleOrange', 'Set2', 'Accent', 'Set1', 'Set3', 'Dark2', 'Paired', 'Pastel2', 'Pastel1', 'Magma', 'Inferno', 'Plasma', 'Viridis', 'Cividis', 'Twilight', 'Rainbow', 'RedWhiteBlue'
@@ -480,7 +508,7 @@ Object with:
       - **highlightColor**: Color as 0xrrggbb
       - **selectColor**: Color as 0xrrggbb
       - **quality**: One of 'custom', 'auto', 'highest', 'higher', 'high', 'medium', 'low', 'lower', 'lowest'
-      - **isoValue**: Numeric value
+      - **isoValueNorm**: Numeric value *(Normalized Isolevel Value)*
       - **renderMode**: One of 'isosurface', 'volume'
       - **controlPoints**: A list of 2d vectors [xi, yi][]
       - **list**: One of 'OrangeRed', 'PurpleBlue', 'BluePurple', 'Oranges', 'BlueGreen', 'YellowOrangeBrown', 'YellowGreen', 'Reds', 'RedPurple', 'Greens', 'YellowGreenBlue', 'Purples', 'GreenBlue', 'Greys', 'YellowOrangeRed', 'PurpleRed', 'Blues', 'PurpleBlueGreen', 'Spectral', 'RedYellowGreen', 'RedBlue', 'PinkYellowGreen', 'PurpleGreen', 'RedYellowBlue', 'BrownWhiteGreen', 'RedGrey', 'PurpleOrange', 'Set2', 'Accent', 'Set1', 'Set3', 'Dark2', 'Paired', 'Pastel2', 'Pastel1', 'Magma', 'Inferno', 'Plasma', 'Viridis', 'Cividis', 'Twilight', 'Rainbow', 'RedWhiteBlue'
diff --git a/src/mol-canvas3d/controls/trackball.ts b/src/mol-canvas3d/controls/trackball.ts
index 7955ab4c5976d0c137f2a88c9ed878cf1632a902..63234d324de095b95e09cd11ad07bd13354d4d95 100644
--- a/src/mol-canvas3d/controls/trackball.ts
+++ b/src/mol-canvas3d/controls/trackball.ts
@@ -209,6 +209,8 @@ namespace TrackballControls {
 
         /** Update the object's position, direction and up vectors */
         function update() {
+            if (p.spin) spin();
+
             Vec3.sub(_eye, object.position, target)
 
             rotateCamera()
@@ -300,7 +302,6 @@ namespace TrackballControls {
         function spin() {
             _spinSpeed[0] = (p.spinSpeed || 0) / 1000;
             if (!_isInteracting) Vec2.add(_moveCurr, _movePrev, _spinSpeed);
-            if (p.spin) requestAnimationFrame(spin);
         }
 
         // force an update at start
@@ -313,9 +314,7 @@ namespace TrackballControls {
 
             get props() { return p as Readonly<TrackballControlsProps> },
             setProps: (props: Partial<TrackballControlsProps>) => {
-                const wasSpinning = p.spin
                 Object.assign(p, props)
-                if (p.spin && !wasSpinning) requestAnimationFrame(spin)
             },
 
             update,
diff --git a/src/mol-plugin/component.ts b/src/mol-plugin/component.ts
index 56577179e12cfb870169c6cee6b0088762182276..fab609d81a056aa2604fc2252da0f604c34fe72e 100644
--- a/src/mol-plugin/component.ts
+++ b/src/mol-plugin/component.ts
@@ -12,12 +12,14 @@ export class PluginComponent<State> {
     private _state: BehaviorSubject<State>;
     private _updated = new Subject();
 
-    updateState(...states: Partial<State>[]) {
+    updateState(...states: Partial<State>[]): boolean {
         const latest = this.latestState;
         const s = shallowMergeArray(latest, states);
         if (s !== latest) {
             this._state.next(s);
+            return true;
         }
+        return false;
     }
 
     get states() {
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index 5895a6c6dfa731ed5240e2b9ff6423a6e6588ffe..9125dc965485a80310fa522cb3526d059ef28ad2 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -178,6 +178,13 @@ export class PluginContext {
         }
     }
 
+    private initAnimations() {
+        if (!this.spec.animations) return;
+        for (const anim of this.spec.animations) {
+            this.state.animation.register(anim);
+        }
+    }
+
     private initCustomParamEditors() {
         if (!this.spec.customParamEditors) return;
 
@@ -193,6 +200,7 @@ export class PluginContext {
 
         this.initBehaviors();
         this.initDataActions();
+        this.initAnimations();
         this.initCustomParamEditors();
 
         this.lociLabels = new LociLabelManager(this);
diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts
index 54e7e1590e56e0970754f3e5083f11a53a6f8bb6..0a3a8d719e6d17fdc6595fb70bda19603f84db8e 100644
--- a/src/mol-plugin/index.ts
+++ b/src/mol-plugin/index.ts
@@ -14,6 +14,7 @@ import { PluginSpec } from './spec';
 import { DownloadStructure, CreateComplexRepresentation, OpenStructure, OpenVolume, DownloadDensity } from './state/actions/basic';
 import { StateTransforms } from './state/transforms';
 import { PluginBehaviors } from './behavior';
+import { AnimateModelIndex } from './state/animation/built-in';
 
 function getParam(name: string, regex: string): string {
     let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
@@ -48,6 +49,9 @@ export const DefaultPluginSpec: PluginSpec = {
         PluginSpec.Behavior(PluginBehaviors.Labels.SceneLabels),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.PDBeStructureQualityReport, { autoAttach: true }),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.RCSBAssemblySymmetry, { autoAttach: true }),
+    ],
+    animations: [
+        AnimateModelIndex
     ]
 }
 
diff --git a/src/mol-plugin/skin/base/components/controls.scss b/src/mol-plugin/skin/base/components/controls.scss
index 3605b3cd9d918adac14de6a5b84ee5c093bfd20f..bd527f69b3c6fa427c0ebe08921aa5539b5374b2 100644
--- a/src/mol-plugin/skin/base/components/controls.scss
+++ b/src/mol-plugin/skin/base/components/controls.scss
@@ -76,19 +76,10 @@
     > div:first-child {
         position: absolute;
         top: 0;
-        left: 0;
+        left: 18px;
         bottom: 0;
-        right: 50px;
-        width: 100%;
-        padding-right: 50px;
-        display: table;
-        
-        > div {
-            height: $row-height;
-            display: table-cell;
-            vertical-align: middle;
-            padding: 0 ($control-spacing + 4px);
-        }
+        right: 62px;
+        display: grid;
     }
     > div:last-child {
         position: absolute;
@@ -101,9 +92,12 @@
         bottom: 0;
     }
     
-    // input[type=text] {
-    //     text-align: right;
-    // }
+    input[type=text] {
+        padding-right: 6px;
+        padding-left: 4px;
+        font-size: 80%;
+        text-align: right;
+    }
     
     // input[type=range] {
     //     width: 100%;
@@ -125,20 +119,10 @@
     > div:nth-child(2) {
         position: absolute;
         top: 0;
-        left: 0;
+        left: 35px;
         bottom: 0;
-        right: 25px;
-        width: 100%;
-        padding-left: 20px;
-        padding-right: 25px;
-        display: table;
-        
-        > div {
-            height: $row-height;
-            display: table-cell;
-            vertical-align: middle;
-            padding: 0 ($control-spacing + 4px);
-        }
+        right: 37px;
+        display: grid;
     }
     > div:last-child {
         position: absolute;
@@ -152,9 +136,12 @@
         font-size: 80%;
     }
     
-    // input[type=text] {
-    //     text-align: right;
-    // }
+    input[type=text] {
+        padding-right: 4px;
+        padding-left: 4px;
+        font-size: 80%;
+        text-align: center;
+    }
     
     // input[type=range] {
     //     width: 100%;
diff --git a/src/mol-plugin/skin/base/components/misc.scss b/src/mol-plugin/skin/base/components/misc.scss
index d11284bcf2feee85acc1e334685872c053da564d..f75ff2101ae6bc1261b1fb69f418f0875365c3b2 100644
--- a/src/mol-plugin/skin/base/components/misc.scss
+++ b/src/mol-plugin/skin/base/components/misc.scss
@@ -66,4 +66,8 @@
     background: white;
     cursor: inherit;
     display: block;
+}
+
+.msp-animation-section {
+    margin-bottom: $control-spacing;
 }
\ No newline at end of file
diff --git a/src/mol-plugin/skin/base/components/slider.scss b/src/mol-plugin/skin/base/components/slider.scss
index 3d879558045cb147effefb5861d7eb6e457c57c2..cc5c2c689c48808b44927d8b015600e9d29c90e4 100644
--- a/src/mol-plugin/skin/base/components/slider.scss
+++ b/src/mol-plugin/skin/base/components/slider.scss
@@ -14,6 +14,7 @@
   padding: 5px 0;
   width: 100%;
   border-radius: $slider-border-radius-base;
+  align-self: center;
   @include borderBox;
 
   &-rail {
diff --git a/src/mol-plugin/spec.ts b/src/mol-plugin/spec.ts
index 7475b6ebd0446cd623ad7de994031eb321530218..4c13c42c677060a31224063e7e088687bc791683 100644
--- a/src/mol-plugin/spec.ts
+++ b/src/mol-plugin/spec.ts
@@ -8,13 +8,15 @@ import { StateAction } from 'mol-state/action';
 import { Transformer } from 'mol-state';
 import { StateTransformParameters } from './ui/state/common';
 import { PluginLayoutStateProps } from './layout';
+import { PluginStateAnimation } from './state/animation/model';
 
 export { PluginSpec }
 
 interface PluginSpec {
     actions: PluginSpec.Action[],
     behaviors: PluginSpec.Behavior[],
-    customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][]
+    animations?: PluginStateAnimation[],
+    customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][],
     initialLayout?: PluginLayoutStateProps
 }
 
diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts
index f94af6388e24c853c4f9ce7e2a39e0a45bd10d90..5e3a349c88082113ea666c92133b15ed44591c7d 100644
--- a/src/mol-plugin/state.ts
+++ b/src/mol-plugin/state.ts
@@ -13,6 +13,7 @@ import { PluginStateSnapshotManager } from './state/snapshots';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { Canvas3DProps } from 'mol-canvas3d/canvas3d';
 import { PluginCommands } from './command';
+import { PluginAnimationManager } from './state/animation/manager';
 export { PluginState }
 
 class PluginState {
@@ -20,6 +21,7 @@ class PluginState {
 
     readonly dataState: State;
     readonly behaviorState: State;
+    readonly animation: PluginAnimationManager;
     readonly cameraSnapshots = new CameraSnapshotManager();
 
     readonly snapshots = new PluginStateSnapshotManager();
@@ -43,6 +45,7 @@ class PluginState {
         return {
             data: this.dataState.getSnapshot(),
             behaviour: this.behaviorState.getSnapshot(),
+            animation: this.animation.getSnapshot(),
             cameraSnapshots: this.cameraSnapshots.getStateSnapshot(),
             canvas3d: {
                 camera: this.plugin.canvas3d.camera.getSnapshot(),
@@ -60,6 +63,9 @@ class PluginState {
             if (snapshot.canvas3d.camera) this.plugin.canvas3d.camera.setState(snapshot.canvas3d.camera);
         }
         this.plugin.canvas3d.requestDraw(true);
+        if (snapshot.animation) {
+            this.animation.setSnapshot(snapshot.animation);
+        }
     }
 
     dispose() {
@@ -81,6 +87,8 @@ class PluginState {
         });
 
         this.behavior.currentObject.next(this.dataState.behaviors.currentObject.value);
+
+        this.animation = new PluginAnimationManager(plugin);
     }
 }
 
@@ -90,6 +98,7 @@ namespace PluginState {
     export interface Snapshot {
         data?: State.Snapshot,
         behaviour?: State.Snapshot,
+        animation?: PluginAnimationManager.Snapshot,
         cameraSnapshots?: CameraSnapshotManager.StateSnapshot,
         canvas3d?: {
             camera?: Camera.Snapshot,
diff --git a/src/mol-plugin/state/animation/built-in.ts b/src/mol-plugin/state/animation/built-in.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cc6ef04c6a3a4e4eb089e625cce42e65e5fcd807
--- /dev/null
+++ b/src/mol-plugin/state/animation/built-in.ts
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginStateAnimation } from './model';
+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';
+
+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 }),
+    async apply(animState, t, ctx) {
+        // limit fps
+        if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) {
+            return { kind: 'skip' };
+        }
+
+        const state = ctx.plugin.state.dataState;
+        const models = state.selectQ(q => q.rootsOfType(PluginStateObject.Molecule.Model)
+            .filter(c => c.transform.transformer === StateTransforms.Model.ModelFromTrajectory));
+
+        const update = state.build();
+
+        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;
+                    return { modelIndex };
+                });
+        }
+
+        await PluginCommands.State.Update.dispatch(ctx.plugin, { state, tree: update });
+        return { kind: 'next', state: { frame: animState.frame + 1 } };
+    }
+})
\ No newline at end of file
diff --git a/src/mol-plugin/state/animation/manager.ts b/src/mol-plugin/state/animation/manager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8ba64a74932f7372782f4977b506c1f1b671aa5e
--- /dev/null
+++ b/src/mol-plugin/state/animation/manager.ts
@@ -0,0 +1,165 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginComponent } from 'mol-plugin/component';
+import { PluginContext } from 'mol-plugin/context';
+import { PluginStateAnimation } from './model';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+
+export { PluginAnimationManager }
+
+// TODO: pause functionality (this needs to reset if the state tree changes)
+// TODO: handle unregistered animations on state restore
+
+class PluginAnimationManager extends PluginComponent<PluginAnimationManager.State> {
+    private map = new Map<string, PluginStateAnimation>();
+    private animations: PluginStateAnimation[] = [];
+    private _current: PluginAnimationManager.Current;
+    private _params?: PD.For<PluginAnimationManager.State['params']> = void 0;
+
+    get isEmpty() { return this.animations.length === 0; }
+    get current() { return this._current!; }
+
+    getParams(): PD.Params {
+        if (!this._params) {
+            this._params = {
+                current: PD.Select(this.animations[0] && this.animations[0].name,
+                    this.animations.map(a => [a.name, a.display.name] as [string, string]),
+                    { label: 'Animation' })
+            };
+        }
+        return this._params as any as PD.Params;
+    }
+
+    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._current = {
+            anim,
+            params,
+            paramValues: PD.getDefaultValues(params),
+            state: {},
+            startedTime: -1,
+            lastTime: 0
+        }
+        this.triggerUpdate();
+    }
+
+    updateCurrentParams(values: any) {
+        this._current.paramValues = { ...this._current.paramValues, ...values };
+        this.triggerUpdate();
+    }
+
+    register(animation: PluginStateAnimation) {
+        if (this.map.has(animation.name)) {
+            this.context.log.error(`Animation '${animation.name}' is already registered.`);
+            return;
+        }
+        this._params = void 0;
+        this.map.set(animation.name, animation);
+        this.animations.push(animation);
+        if (this.animations.length === 1) {
+            this.updateParams({ current: animation.name });
+        } else {
+            this.triggerUpdate();
+        }
+    }
+
+    start() {
+        this.updateState({ animationState: 'playing' });
+        this.triggerUpdate();
+
+        this._current.lastTime = 0;
+        this._current.startedTime = -1;
+        this._current.state = this._current.anim.initialState(this._current.paramValues, this.context);
+
+        requestAnimationFrame(this.animate);
+    }
+
+    stop() {
+        this.updateState({ animationState: 'stopped' });
+        this.triggerUpdate();
+    }
+
+    private animate = async (t: number) => {
+        if (this._current.startedTime < 0) this._current.startedTime = t;
+        const newState = await this._current.anim.apply(
+            this._current.state,
+            { lastApplied: this._current.lastTime, current: t - this._current.startedTime },
+            { params: this._current.paramValues, plugin: this.context });
+
+        if (newState.kind === 'finished') {
+            this.stop();
+        } 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);
+        } else if (newState.kind === 'skip') {
+            if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate);
+        }
+    }
+
+    getSnapshot(): PluginAnimationManager.Snapshot {
+        if (!this.current) return { state: this.latestState };
+
+        return {
+            state: this.latestState,
+            current: {
+                paramValues: this._current.paramValues,
+                state: this._current.anim.stateSerialization ? this._current.anim.stateSerialization.toJSON(this._current.state) : this._current.state
+            }
+        };
+    }
+
+    setSnapshot(snapshot: PluginAnimationManager.Snapshot) {
+        this.updateState({ animationState: snapshot.state.animationState });
+        this.updateParams(snapshot.state.params);
+
+        if (snapshot.current) {
+            this.current.paramValues = snapshot.current.paramValues;
+            this.current.state = this._current.anim.stateSerialization
+                ? this._current.anim.stateSerialization.fromJSON(snapshot.current.state)
+                : snapshot.current.state;
+            this.triggerUpdate();
+            if (this.latestState.animationState === 'playing') this.resume();
+        }
+    }
+
+    private resume() {
+        this._current.lastTime = 0;
+        this._current.startedTime = -1;
+        requestAnimationFrame(this.animate);
+    }
+
+    constructor(ctx: PluginContext) {
+        super(ctx, { params: { current: '' }, animationState: 'stopped' });
+    }
+}
+
+namespace PluginAnimationManager {
+    export interface Current {
+        anim: PluginStateAnimation
+        params: PD.Params,
+        paramValues: any,
+        state: any,
+        startedTime: number,
+        lastTime: number
+    }
+
+    export interface State {
+        params: { current: string },
+        animationState: 'stopped' | 'playing'
+    }
+
+    export interface Snapshot {
+        state: State,
+        current?: {
+            paramValues: any,
+            state: any
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/mol-plugin/state/animation/model.ts b/src/mol-plugin/state/animation/model.ts
new file mode 100644
index 0000000000000000000000000000000000000000..82ba43ca9f3fccb271e97d59388221ea2f02d62b
--- /dev/null
+++ b/src/mol-plugin/state/animation/model.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { PluginContext } from 'mol-plugin/context';
+
+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> {
+    name: string,
+    readonly display: { readonly name: string, readonly description?: string },
+    params: (ctx: PluginContext) => P,
+    initialState(params: PD.Values<P>, ctx: PluginContext): S,
+
+    /**
+     * Apply the current frame and modify the state.
+     * @param t Current absolute time since the animation started.
+     */
+    apply(state: S, t: PluginStateAnimation.Time, ctx: PluginStateAnimation.Context<P>): Promise<PluginStateAnimation.ApplyResult<S>>,
+
+    /**
+     * The state must be serializable to JSON. If JSON.stringify is not enough,
+     * custom converted to an object that works with JSON.stringify can be provided.
+     */
+    stateSerialization?: { toJSON(state: S): any, fromJSON(data: any): S }
+}
+
+namespace PluginStateAnimation {
+    export interface Time {
+        lastApplied: number,
+        current: number
+    }
+
+    export type ApplyResult<S> = { kind: 'finished' } | { kind: 'skip' } | { kind: 'next', state: S }
+    export interface Context<P extends PD.Params> {
+        params: PD.Values<P>,
+        plugin: PluginContext
+    }
+
+    export function create<P extends PD.Params, S>(params: PluginStateAnimation<P, S>) {
+        return params;
+    }
+}
\ No newline at end of file
diff --git a/src/mol-plugin/ui/controls/common.tsx b/src/mol-plugin/ui/controls/common.tsx
index 5c33082538ebac60e3a6d2f2bdb37f0b7bc61a4b..4a9588fbeab6903662439f8576775f9068f00c68 100644
--- a/src/mol-plugin/ui/controls/common.tsx
+++ b/src/mol-plugin/ui/controls/common.tsx
@@ -27,6 +27,59 @@ export class ControlGroup extends React.Component<{ header: string, initialExpan
     }
 }
 
+export class NumericInput extends React.PureComponent<{
+    value: number,
+    onChange: (v: number) => void,
+    onEnter?: () => void,
+    blurOnEnter?: boolean,
+    isDisabled?: boolean,
+    placeholder?: string
+}, { value: string }> {
+    state = { value: '0' };
+    input = React.createRef<HTMLInputElement>();
+
+    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        const value = +e.target.value;
+        this.setState({ value: e.target.value }, () => {
+            if (!Number.isNaN(value) && value !== this.props.value) {
+                this.props.onChange(value);
+            }
+        });
+    }
+
+    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+        if ((e.keyCode === 13 || e.charCode === 13)) {
+            if (this.props.blurOnEnter && this.input.current) {
+                this.input.current.blur();
+            }
+            if (this.props.onEnter) this.props.onEnter();
+        }
+    }
+
+    onBlur = () => {
+        this.setState({ value: '' + this.props.value });
+    }
+
+    static getDerivedStateFromProps(props: { value: number }, state: { value: string }) {
+        const value = +state.value;
+        if (Number.isNaN(value) || value === props.value) return null;
+        return { value: '' + props.value };
+    }
+
+    render() {
+        return <input type='text'
+            ref={this.input}
+            onBlur={this.onBlur}
+            value={this.state.value}
+            placeholder={this.props.placeholder}
+            onChange={this.onChange}
+            onKeyPress={this.props.onEnter || this.props.blurOnEnter ? this.onKeyPress : void 0}
+            disabled={!!this.props.isDisabled}
+        />
+    }
+}
+
+
 // export const ToggleButton = (props: {
 //     onChange: (v: boolean) => void,
 //     value: boolean,
diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx
index caa63c87a96e8ed267212070ff1285fd947c3161..ad7ef082d48cf230cbe745d3e4bed7a610d6b545 100644
--- a/src/mol-plugin/ui/controls/parameters.tsx
+++ b/src/mol-plugin/ui/controls/parameters.tsx
@@ -15,6 +15,7 @@ import { camelCaseToWords } from 'mol-util/string';
 import * as React from 'react';
 import LineGraphComponent from './line-graph/line-graph-component';
 import { Slider, Slider2 } from './slider';
+import { NumericInput } from './common';
 
 export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
     params: P,
@@ -152,53 +153,22 @@ export class LineGraphControl extends React.PureComponent<ParamProps<PD.LineGrap
     }
 }
 
-export class NumberInputControl extends React.PureComponent<ParamProps<PD.Numeric>, { value: string }> {
+export class NumberInputControl extends React.PureComponent<ParamProps<PD.Numeric>> {
     state = { value: '0' };
 
-    protected update(value: any) {
+    update = (value: number) => {
         this.props.onChange({ param: this.props.param, name: this.props.name, value });
     }
 
-    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-        const value = +e.target.value;
-        this.setState({ value: e.target.value }, () => {
-            if (!Number.isNaN(value) && value !== this.props.value) {
-                this.update(value);
-            }
-        });
-    }
-
-    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
-        if (!this.props.onEnter) return;
-        if ((e.keyCode === 13 || e.charCode === 13)) {
-            this.props.onEnter();
-        }
-    }
-
-    onBlur = () => {
-        this.setState({ value: '' + this.props.value });
-    }
-
-    static getDerivedStateFromProps(props: { value: number }, state: { value: string }) {
-        const value = +state.value;
-        if (Number.isNaN(value) || value === props.value) return null;
-        return { value: '' + props.value };
-    }
-
     render() {
         const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
         const label = this.props.param.label || camelCaseToWords(this.props.name);
         return <div className='msp-control-row'>
             <span title={this.props.param.description}>{label}</span>
             <div>
-                <input type='text'
-                    onBlur={this.onBlur}
-                    value={this.state.value}
-                    placeholder={placeholder}
-                    onChange={this.onChange}
-                    onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
-                    disabled={this.props.isDisabled}
-                />
+                <NumericInput
+                    value={this.props.value} onEnter={this.props.onEnter} placeholder={placeholder}
+                    isDisabled={this.props.isDisabled} onChange={this.update} />
             </div>
         </div>;
     }
@@ -208,7 +178,7 @@ export class NumberRangeControl extends SimpleParam<PD.Numeric> {
     onChange = (v: number) => { this.update(v); }
     renderControl() {
         return <Slider value={this.props.value} min={this.props.param.min!} max={this.props.param.max!}
-            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} />
+            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} onEnter={this.props.onEnter} />
     }
 }
 
@@ -267,7 +237,7 @@ export class BoundedIntervalControl extends SimpleParam<PD.Interval> {
     onChange = (v: [number, number]) => { this.update(v); }
     renderControl() {
         return <Slider2 value={this.props.value} min={this.props.param.min!} max={this.props.param.max!}
-            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} />;
+            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} onEnter={this.props.onEnter} />;
     }
 }
 
diff --git a/src/mol-plugin/ui/controls/slider.tsx b/src/mol-plugin/ui/controls/slider.tsx
index f930a20fe558d0496ba4eeec0689a97ad4bb506d..c807995fbad0abecd3e93939dbb245ff3b3ac952 100644
--- a/src/mol-plugin/ui/controls/slider.tsx
+++ b/src/mol-plugin/ui/controls/slider.tsx
@@ -5,6 +5,7 @@
  */
 
 import * as React from 'react'
+import { NumericInput } from './common';
 
 export class Slider extends React.Component<{
     min: number,
@@ -12,7 +13,8 @@ export class Slider extends React.Component<{
     value: number,
     step?: number,
     onChange: (v: number) => void,
-    disabled?: boolean
+    disabled?: boolean,
+    onEnter?: () => void
 }, { isChanging: boolean, current: number }> {
 
     state = { isChanging: false, current: 0 }
@@ -35,18 +37,27 @@ export class Slider extends React.Component<{
         this.setState({ current });
     }
 
+    updateManually = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.props.min) n = this.props.min;
+        if (n > this.props.max) n = this.props.max;
+        this.props.onChange(n);
+    }
+
     render() {
         let step = this.props.step;
         if (step === void 0) step = 1;
         return <div className='msp-slider'>
             <div>
-                <div>
-                    <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
-                        onBeforeChange={this.begin}
-                        onChange={this.updateCurrent as any} onAfterChange={this.end as any} />
-                </div></div>
+                <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
+                    onBeforeChange={this.begin}
+                    onChange={this.updateCurrent as any} onAfterChange={this.end as any} />
+            </div>
             <div>
-                {`${Math.round(100 * this.state.current) / 100}`}
+                <NumericInput
+                    value={this.state.current} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateManually} />
             </div>
         </div>;
     }
@@ -58,7 +69,8 @@ export class Slider2 extends React.Component<{
     value: [number, number],
     step?: number,
     onChange: (v: [number, number]) => void,
-    disabled?: boolean
+    disabled?: boolean,
+    onEnter?: () => void
 }, { isChanging: boolean, current: [number, number] }> {
 
     state = { isChanging: false, current: [0, 1] as [number, number] }
@@ -81,20 +93,41 @@ export class Slider2 extends React.Component<{
         this.setState({ current });
     }
 
+    updateMax = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.state.current[0]) n = this.state.current[0]
+        else if (n < this.props.min) n = this.props.min;
+        if (n > this.props.max) n = this.props.max;
+        this.props.onChange([this.state.current[0], n]);
+    }
+
+    updateMin = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.props.min) n = this.props.min;
+        if (n > this.state.current[1]) n = this.state.current[1];
+        else if (n > this.props.max) n = this.props.max;
+        this.props.onChange([n, this.state.current[1]]);
+    }
+
     render() {
         let step = this.props.step;
         if (step === void 0) step = 1;
         return <div className='msp-slider2'>
             <div>
-                {`${Math.round(100 * this.state.current[0]) / 100}`}
+                <NumericInput
+                    value={this.state.current[0]} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateMin} />
             </div>
             <div>
-                <div>
-                    <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
-                        onBeforeChange={this.begin} onChange={this.updateCurrent as any} onAfterChange={this.end as any} range={true} pushable={true} />
-                </div></div>
+                <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
+                    onBeforeChange={this.begin} onChange={this.updateCurrent as any} onAfterChange={this.end as any} range={true} pushable={true} />
+            </div>
             <div>
-                {`${Math.round(100 * this.state.current[1]) / 100}`}
+                <NumericInput
+                    value={this.state.current[1]} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateMax} />
             </div>
         </div>;
     }
@@ -102,10 +135,10 @@ export class Slider2 extends React.Component<{
 
 /**
  * The following code was adapted from react-components/slider library.
- * 
+ *
  * The MIT License (MIT)
  * Copyright (c) 2015-present Alipay.com, https://www.alipay.com/
- * 
+ *
  * Permission is hereby granted, free of charge, to any person obtaining a copy
  * of this software and associated documentation files (the "Software"), to deal
  * in the Software without restriction, including without limitation the rights
@@ -116,12 +149,12 @@ export class Slider2 extends React.Component<{
  * The above copyright notice and this permission notice shall be included in
  * all copies or substantial portions of the Software.
 
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
- * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 
- * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 
- * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 
- * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
@@ -540,7 +573,7 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
         }
         return false;
 
-        // return this.state.bounds.some((x, i) => e.target 
+        // return this.state.bounds.some((x, i) => e.target
 
         // (
         //     //this.handleElements[i] && e.target === ReactDOM.findDOMNode(this.handleElements[i])
@@ -702,7 +735,7 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
             dragging: handle === i,
             index: i,
             key: i,
-            ref: (h: any) => this.handleElements.push(h)  //`handle-${i}`,
+            ref: (h: any) => this.handleElements.push(h)  // `handle-${i}`,
         }));
         if (!range) { handles.shift(); }
 
diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx
index 3118cc358799d9a90efd78eae81f6d9c2c492ec1..e465a67a623ddfb0cf563a02590ad1bcbbeb53c5 100644
--- a/src/mol-plugin/ui/plugin.tsx
+++ b/src/mol-plugin/ui/plugin.tsx
@@ -20,6 +20,7 @@ import { ApplyActionContol } from './state/apply-action';
 import { PluginState } from 'mol-plugin/state';
 import { UpdateTransformContol } from './state/update-transform';
 import { StateObjectCell } from 'mol-state';
+import { AnimationControls } from './state/animation';
 
 export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
 
@@ -61,6 +62,7 @@ class Layout extends PluginComponent {
                     {layout.showControls && this.region('right', <div className='msp-scrollable-container msp-right-controls'>
                         <CurrentObject />
                         <Controls />
+                        <AnimationControls />
                         <CameraSnapshots />
                         <StateSnapshots />
                     </div>)}
diff --git a/src/mol-plugin/ui/state-tree.tsx b/src/mol-plugin/ui/state-tree.tsx
index 87123f35abcb6f93a966933e488c5815939f8328..59a5fc8c260476607d79dfe8a2bac39fd5ff1596 100644
--- a/src/mol-plugin/ui/state-tree.tsx
+++ b/src/mol-plugin/ui/state-tree.tsx
@@ -177,10 +177,6 @@ class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State
         const children = this.props.state.tree.children.get(this.props.nodeRef);
         const cellState = this.props.state.cellStates.get(this.props.nodeRef);
 
-        const remove = <button onClick={this.remove} className='msp-btn msp-btn-link msp-tree-remove-button'>
-            <span className='msp-icon msp-icon-remove' />
-        </button>;
-
         const visibility = <button onClick={this.toggleVisible} className={`msp-btn msp-btn-link msp-tree-visibility${cellState.isHidden ? ' msp-tree-visibility-hidden' : ''}`}>
             <span className='msp-icon msp-icon-visual-visibility' />
         </button>;
@@ -190,7 +186,9 @@ class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State
             {children.size > 0 &&  <button onClick={this.toggleExpanded} className='msp-btn msp-btn-link msp-tree-toggle-exp-button'>
                 <span className={`msp-icon msp-icon-${cellState.isCollapsed ? 'expand' : 'collapse'}`} />
             </button>}
-            {remove}{visibility}
+            {!cell.transform.props.isLocked && <button onClick={this.remove} className='msp-btn msp-btn-link msp-tree-remove-button'>
+                <span className='msp-icon msp-icon-remove' />
+            </button>}{visibility}
         </div>
     }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/ui/state/animation.tsx b/src/mol-plugin/ui/state/animation.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c25fc4edaa18045c164b7eb0395a23912a000b8d
--- /dev/null
+++ b/src/mol-plugin/ui/state/animation.tsx
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { PluginComponent } from '../base';
+import { ParameterControls, ParamOnChange } from '../controls/parameters';
+
+export class AnimationControls extends PluginComponent<{ }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.state.animation.updated, () => this.forceUpdate());
+    }
+
+    updateParams: ParamOnChange = p => {
+        this.plugin.state.animation.updateParams({ [p.name]: p.value });
+    }
+
+    updateCurrentParams: ParamOnChange = p => {
+        this.plugin.state.animation.updateCurrentParams({ [p.name]: p.value });
+    }
+
+    startOrStop = () => {
+        const anim = this.plugin.state.animation;
+        if (anim.latestState.animationState === 'playing') anim.stop();
+        else anim.start();
+    }
+
+    render() {
+        const anim = this.plugin.state.animation;
+        if (anim.isEmpty) return null;
+
+        const isDisabled = anim.latestState.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.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'}
+                </button>
+            </div>
+        </div>
+    }
+}
\ No newline at end of file
diff --git a/src/mol-state/object.ts b/src/mol-state/object.ts
index 3e2df3343df0b95b85698bbbbdabcfbda6dbbbd6..e8bdf73751d8d4b7fe63d5cffb7c6a834ba4204a 100644
--- a/src/mol-state/object.ts
+++ b/src/mol-state/object.ts
@@ -53,7 +53,7 @@ namespace StateObject {
     };
 }
 
-interface StateObjectCell {
+interface StateObjectCell<T = StateObject> {
     transform: Transform,
 
     // Which object was used as a parent to create data in this cell
@@ -68,7 +68,7 @@ interface StateObjectCell {
     } | undefined;
 
     errorText?: string,
-    obj?: StateObject
+    obj?: T
 }
 
 namespace StateObjectCell {
diff --git a/src/mol-state/state/selection.ts b/src/mol-state/state/selection.ts
index 5bb6010f5805c52ee41a79c8d31eac078229d648..828f7c9cf85a0926dae10d13bc6c675094939b64 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;
+        return (arg as StateObjectCell).version !== void 0 && (arg as StateObjectCell).transform !== void 0;
     }
 
     function isBuilder(arg: any): arg is Builder {
diff --git a/src/mol-state/transform.ts b/src/mol-state/transform.ts
index 86edad5a9e06d2fd28e0ed74c76b1a4085c046a6..dea70d4da12f4fa6ed617773dc26aa4bb13a331b 100644
--- a/src/mol-state/transform.ts
+++ b/src/mol-state/transform.ts
@@ -25,7 +25,9 @@ export namespace Transform {
     export interface Props {
         tag?: string
         isGhost?: boolean,
-        isBinding?: boolean
+        isBinding?: boolean,
+        // determine if the corresponding cell can be deleted by the user.
+        isLocked?: boolean
     }
 
     export interface Options {
diff --git a/src/mol-state/transformer.ts b/src/mol-state/transformer.ts
index b10778f9f0741a0fbf4e80af14759489638f936a..3693eb5cae8aee0b7b20c2976c0dd9139727d7f5 100644
--- a/src/mol-state/transformer.ts
+++ b/src/mol-state/transformer.ts
@@ -5,7 +5,7 @@
  */
 
 import { Task } from 'mol-task';
-import { StateObject } from './object';
+import { StateObject, StateObjectCell } from './object';
 import { Transform } from './transform';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { StateAction } from './action';
@@ -24,6 +24,7 @@ export namespace Transformer {
     export type Params<T extends Transformer<any, any, any>> = T extends Transformer<any, any, infer P> ? P : unknown;
     export type From<T extends Transformer<any, any, any>> = T extends Transformer<infer A, any, any> ? A : unknown;
     export type To<T extends Transformer<any, any, any>> = T extends Transformer<any, infer B, any> ? B : unknown;
+    export type Cell<T extends Transformer<any, any, any>> = T extends Transformer<any, infer B, any> ? StateObjectCell<B> : unknown;
 
     export function is(obj: any): obj is Transformer {
         return !!obj && typeof (obj as Transformer).toAction === 'function' && typeof (obj as Transformer).apply === 'function';