diff --git a/src/apps/state-docs/pd-to-md.ts b/src/apps/state-docs/pd-to-md.ts
index b285668073b8b80f070ff20d1b201f45fcda0943..310962f40f1f96d053db19466506e87595dfc8da 100644
--- a/src/apps/state-docs/pd-to-md.ts
+++ b/src/apps/state-docs/pd-to-md.ts
@@ -28,6 +28,8 @@ function paramInfo(param: PD.Any, offset: number): string {
         case 'group': return `Object with:\n${getParams(param.params, offset + 2)}`;
         case 'mapped': return `Object { name: string, params: object } where name+params are:\n${getMapped(param, offset + 2)}`;
         case 'line-graph': return `A list of 2d vectors [xi, yi][]`;
+        // TODO: support more languages
+        case 'script-expression': return `An expression in the specified language { language: 'mol-script', expressiong: string }`;
         default:
             const _: never = param;
             console.warn(`${_} has no associated UI component`);
diff --git a/src/apps/viewer/index.ts b/src/apps/viewer/index.ts
index 947f1be45eedcbdac2a0b5c8a131ba086ce57345..95ee7373824184c9efa64981504787cf8ab6cb6f 100644
--- a/src/apps/viewer/index.ts
+++ b/src/apps/viewer/index.ts
@@ -6,14 +6,39 @@
 
 import { createPlugin, DefaultPluginSpec } from 'mol-plugin';
 import './index.html'
+import { PluginContext } from 'mol-plugin/context';
+import { PluginCommands } from 'mol-plugin/command';
 require('mol-plugin/skin/light.scss')
 
-createPlugin(document.getElementById('app')!, {
-    ...DefaultPluginSpec,
-    layout: {
-        initial: {
-            isExpanded: true,
-            showControls: true
+function getParam(name: string, regex: string): string {
+    let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
+    return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || '');
+}
+
+const hideControls = getParam('hide-controls', `[^&]+`) === '1';
+
+function init() {
+    const plugin = createPlugin(document.getElementById('app')!, {
+        ...DefaultPluginSpec,
+        layout: {
+            initial: {
+                isExpanded: true,
+                showControls: !hideControls
+            }
         }
+    });
+    trySetSnapshot(plugin);
+}
+
+async function trySetSnapshot(ctx: PluginContext) {
+    try {
+        const snapshotUrl = getParam('snapshot-url', `[^&]+`);
+        if (!snapshotUrl) return;
+        await PluginCommands.State.Snapshots.Fetch.dispatch(ctx, { url: snapshotUrl })
+    } catch (e) {
+        ctx.log.error('Failed to load snapshot.');
+        console.warn('Failed to load snapshot', e);
     }
-});
\ No newline at end of file
+}
+
+init();
\ No newline at end of file
diff --git a/src/mol-model/structure/structure/element.ts b/src/mol-model/structure/structure/element.ts
index 7d182793309d9163aa212152827bb349c026cb98..3e0ec4ec82004ed7b7d30d7d4a56716a61a35acd 100644
--- a/src/mol-model/structure/structure/element.ts
+++ b/src/mol-model/structure/structure/element.ts
@@ -254,11 +254,19 @@ namespace StructureElement {
             }
             if (loci.elements.length === 0) return MS.struct.generator.empty();
 
-            const sourceIndices = UniqueArray.create<number, number>();
+            const sourceIndexMap = new Map<string, UniqueArray<number, number>>();
             const el = StructureElement.create(), p = StructureProperties.atom.sourceIndex;
             for (const e of loci.elements) {
                 const { indices } = e;
                 const { elements } = e.unit;
+                const opName = e.unit.conformation.operator.name;
+
+                let sourceIndices: UniqueArray<number, number>;
+                if (sourceIndexMap.has(opName)) sourceIndices = sourceIndexMap.get(opName)!;
+                else {
+                    sourceIndices = UniqueArray.create<number, number>();
+                    sourceIndexMap.set(opName, sourceIndices);
+                }
 
                 el.unit = e.unit;
                 for (let i = 0, _i = OrderedSet.size(indices); i < _i; i++) {
@@ -268,7 +276,20 @@ namespace StructureElement {
                 }
             }
 
-            const xs = sourceIndices.array;
+            const byOpName: Expression[] = [];
+            const keys = sourceIndexMap.keys();
+            while (true) {
+                const k = keys.next();
+                if (k.done) break;
+                byOpName.push(getOpNameQuery(k.value, sourceIndexMap.get(k.value)!.array));
+            }
+
+            return MS.struct.modifier.union([
+                byOpName.length === 1 ? byOpName[0] : MS.struct.combinator.merge.apply(null, byOpName)
+            ]);
+        }
+
+        function getOpNameQuery(opName: string, xs: number[]) {
             sortArray(xs);
 
             const ranges: number[] = [];
@@ -304,7 +325,7 @@ namespace StructureElement {
 
             return MS.struct.generator.atomGroups({
                 'atom-test': tests.length > 1 ? MS.core.logic.or.apply(null, tests) : tests[0],
-                'group-by': 0
+                'chain-test': MS.core.rel.eq([MS.struct.atomProperty.core.operatorName(), opName])
             });
         }
     }
diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
index 2f5f155083f00ae7589affdecf5998523ad1defe..1e2f8331c1bd92eba141fe66224c696b0775b854 100644
--- a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
+++ b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts
@@ -19,7 +19,6 @@ import { LRUCache } from 'mol-util/lru-cache';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { urlCombine } from 'mol-util/url';
 import { VolumeServerHeader, VolumeServerInfo } from './model';
-import { CreateVolumeStreamingBehavior } from './transformers';
 import { ButtonsType } from 'mol-util/input/input-observer';
 import { PluginCommands } from 'mol-plugin/command';
 import { StateSelection } from 'mol-state';
@@ -93,7 +92,7 @@ export namespace VolumeStreaming {
 
     export class Behavior extends PluginBehavior.WithSubscribers<Params> {
         private cache = LRUCache.create<ChannelsData>(25);
-        private params: Params = {} as any;
+        public params: Params = {} as any;
         // private ref: string = '';
 
         channels: Channels = {}
@@ -151,19 +150,19 @@ export namespace VolumeStreaming {
         private updateDynamicBox(ref: string, box: Box3D) {
             if (this.params.view.name !== 'selection-box') return;
 
-            const eR = this.params.view.params.radius;
             const state = this.plugin.state.dataState;
-            const update = state.build().to(ref).update(CreateVolumeStreamingBehavior, old => ({
-                ...old,
+            const newParams: Params = {
+                ...this.params,
                 view: {
                     name: 'selection-box' as 'selection-box',
                     params: {
-                        radius: eR,
+                        radius: this.params.view.params.radius,
                         bottomLeft: box.min,
                         topRight: box.max
                     }
                 }
-            }));
+            };
+            const update = state.build().to(ref).update(newParams);
 
             PluginCommands.State.Update.dispatch(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
         }
@@ -251,6 +250,13 @@ export namespace VolumeStreaming {
             };
         }
 
+        getDescription() {
+            if (this.params.view.name === 'selection-box') return 'Selection';
+            if (this.params.view.name === 'box') return 'Static Box';
+            if (this.params.view.name === 'cell') return 'Cell';
+            return '';
+        }
+
         constructor(public plugin: PluginContext, public info: VolumeServerInfo.Data) {
             super(plugin);
         }
diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
index bca90d1535a3cf93682cfb4a89001b0822af2748..5ad5d37985b1770a7be5689e5961c1c84e95dcee 100644
--- a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
+++ b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
@@ -18,6 +18,8 @@ import { VolumeStreaming } from './behavior';
 import { VolumeRepresentation3DHelpers } from 'mol-plugin/state/transforms/representation';
 import { BuiltInVolumeRepresentations } from 'mol-repr/volume/registry';
 import { createTheme } from 'mol-theme/theme';
+import { Box3D } from 'mol-math/geometry';
+import { Vec3 } from 'mol-math/linear-algebra';
 // import { PluginContext } from 'mol-plugin/context';
 
 export const InitVolumeStreaming = StateAction.build({
@@ -65,6 +67,29 @@ export const InitVolumeStreaming = StateAction.build({
     await state.updateTree(behTree).runInContext(taskCtx);
 }));
 
+export const BoxifyVolumeStreaming = StateAction.build({
+    display: { name: 'Boxify Volume Streaming', description: 'Make the current box permanent.' },
+    from: VolumeStreaming,
+    isApplicable: (a) => a.data.params.view.name === 'selection-box'
+})(({ a, ref, state }, plugin: PluginContext) => {
+    const params = a.data.params;
+    if (params.view.name !== 'selection-box') return;
+    const box = Box3D.create(Vec3.clone(params.view.params.bottomLeft), Vec3.clone(params.view.params.topRight));
+    const r = params.view.params.radius;
+    Box3D.expand(box, box, Vec3.create(r, r, r));
+    const newParams: VolumeStreaming.Params = {
+        ...params,
+        view: {
+            name: 'box' as 'box',
+            params: {
+                bottomLeft: box.min,
+                topRight: box.max
+            }
+        }
+    };
+    return state.updateTree(state.build().to(ref).update(newParams));
+});
+
 export { CreateVolumeStreamingInfo }
 type CreateVolumeStreamingInfo = typeof CreateVolumeStreamingInfo
 const CreateVolumeStreamingInfo = PluginStateTransform.BuiltIn({
@@ -120,11 +145,13 @@ const CreateVolumeStreamingBehavior = PluginStateTransform.BuiltIn({
     apply: ({ a, params }, plugin: PluginContext) => Task.create('Volume streaming', async _ => {
         const behavior = new VolumeStreaming.Behavior(plugin, a.data);
         await behavior.update(params);
-        return new VolumeStreaming(behavior, { label: 'Volume Streaming' });
+        return new VolumeStreaming(behavior, { label: 'Volume Streaming', description: behavior.getDescription() });
     }),
     update({ b, newParams }) {
         return Task.create('Update Volume Streaming', async _ => {
-            return await b.data.update(newParams) ? StateTransformer.UpdateResult.Updated : StateTransformer.UpdateResult.Unchanged;
+            const ret = await b.data.update(newParams) ? StateTransformer.UpdateResult.Updated : StateTransformer.UpdateResult.Unchanged;
+            b.description = b.data.getDescription();
+            return ret;
         });
     }
 });
diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts
index 6d28606edd90159610d37359df45cea1bf140af0..dd444dd5fe757e88a4ad7db9a7a24bc22cafd407 100644
--- a/src/mol-plugin/behavior/static/state.ts
+++ b/src/mol-plugin/behavior/static/state.ts
@@ -128,10 +128,18 @@ export function Snapshots(ctx: PluginContext) {
     });
 
     PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => {
-        const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), name, description);
+        const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), { name, description });
         ctx.state.snapshots.add(entry);
     });
 
+    PluginCommands.State.Snapshots.Replace.subscribe(ctx, ({ id, params }) => {
+        ctx.state.snapshots.replace(id, ctx.state.getSnapshot(params));
+    });
+
+    PluginCommands.State.Snapshots.Move.subscribe(ctx, ({ id, dir }) => {
+        ctx.state.snapshots.move(id, dir);
+    });
+
     PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => {
         const snapshot = ctx.state.snapshots.setCurrent(id);
         if (!snapshot) return;
@@ -150,9 +158,7 @@ export function Snapshots(ctx: PluginContext) {
 
     PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => {
         const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); //  fetch(url, { referrer: 'no-referrer' });
-        const current = ctx.state.snapshots.setRemoteSnapshot(json.data);
-        if (!current) return;
-        return ctx.state.setSnapshot(current);
+        await ctx.state.snapshots.setRemoteSnapshot(json.data);
     });
 
     PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => {
@@ -169,8 +175,8 @@ export function Snapshots(ctx: PluginContext) {
     PluginCommands.State.Snapshots.OpenFile.subscribe(ctx, async ({ file }) => {
         try {
             const data = await readFromFile(file, 'string').run();
-            const json = JSON.parse(data as string);
-            await ctx.state.setSnapshot(json);
+            const snapshot = JSON.parse(data as string);
+            return ctx.state.setSnapshot(snapshot);
         } catch (e) {
             ctx.log.error(`Reading JSON state: ${e}`);
         }
diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts
index 29e8929f1ac8d5840eb3520fa1a2bb72abc8ac8e..73accd0fc786cd73132a44538dadf92e8ac93b1f 100644
--- a/src/mol-plugin/command.ts
+++ b/src/mol-plugin/command.ts
@@ -29,6 +29,8 @@ export const PluginCommands = {
 
         Snapshots: {
             Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
+            Replace: PluginCommand<{ id: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
+            Move: PluginCommand<{ id: string, dir: -1 | 1 }>({ isImmediate: true }),
             Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
             Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
             Clear: PluginCommand<{}>({ isImmediate: true }),
diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts
index 7fd0a9a0c3dfb71f633dc80cbbe4e630d9be3510..58a870c7682f37ee7f610b123dfdd1d146364785 100644
--- a/src/mol-plugin/context.ts
+++ b/src/mol-plugin/context.ts
@@ -66,6 +66,10 @@ export class PluginContext {
     };
 
     readonly behaviors = {
+        state: {
+            isAnimating: this.ev.behavior<boolean>(false),
+            isUpdating: this.ev.behavior<boolean>(false)
+        },
         canvas3d: {
             highlight: this.ev.behavior<Canvas3D.HighlightEvent>({ current: Representation.Loci.Empty, prev: Representation.Loci.Empty }),
             click: this.ev.behavior<Canvas3D.ClickEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0 })
@@ -159,6 +163,12 @@ export class PluginContext {
         return PluginCommands.State.Update.dispatch(this, { state, tree });
     }
 
+    private initBehaviorEvents() {
+        merge(this.state.dataState.events.isUpdating, this.state.behaviorState.events.isUpdating).subscribe(u => {
+            this.behaviors.state.isUpdating.next(u);
+        });
+    }
+
     private initBuiltInBehavior() {
         BuiltInPluginBehaviors.State.registerDefault(this);
         BuiltInPluginBehaviors.Representation.registerDefault(this);
@@ -206,6 +216,7 @@ export class PluginContext {
     constructor(public spec: PluginSpec) {
         this.events.log.subscribe(e => this.log.entries = this.log.entries.push(e));
 
+        this.initBehaviorEvents();
         this.initBuiltInBehavior();
 
         this.initBehaviors();
diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts
index ffcf45afb01e8dc7f062bf432aec39cfea45b557..95d5b9673a3fc14fd36ab38b69714d0f9d6e708a 100644
--- a/src/mol-plugin/index.ts
+++ b/src/mol-plugin/index.ts
@@ -9,20 +9,14 @@ import { PluginContext } from './context';
 import { Plugin } from './ui/plugin'
 import * as React from 'react';
 import * as ReactDOM from 'react-dom';
-import { PluginCommands } from './command';
 import { PluginSpec } from './spec';
 import { StateTransforms } from './state/transforms';
 import { PluginBehaviors } from './behavior';
 import { AnimateModelIndex } from './state/animation/built-in';
 import { StateActions } from './state/actions';
-import { InitVolumeStreaming } from './behavior/dynamic/volume-streaming/transformers';
+import { InitVolumeStreaming, BoxifyVolumeStreaming, CreateVolumeStreamingBehavior } from './behavior/dynamic/volume-streaming/transformers';
 import { StructureRepresentationInteraction } from './behavior/dynamic/selection/structure-representation-interaction';
 
-function getParam(name: string, regex: string): string {
-    let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
-    return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || '');
-}
-
 export const DefaultPluginSpec: PluginSpec = {
     actions: [
         PluginSpec.Action(StateActions.Structure.DownloadStructure),
@@ -31,8 +25,10 @@ export const DefaultPluginSpec: PluginSpec = {
         PluginSpec.Action(StateActions.Structure.CreateComplexRepresentation),
         PluginSpec.Action(StateActions.Structure.EnableModelCustomProps),
 
-        // PluginSpec.Action(StateActions.Volume.InitVolumeStreaming),
+        // Volume streaming
         PluginSpec.Action(InitVolumeStreaming),
+        PluginSpec.Action(BoxifyVolumeStreaming),
+        PluginSpec.Action(CreateVolumeStreamingBehavior),
 
         PluginSpec.Action(StateTransforms.Data.Download),
         PluginSpec.Action(StateTransforms.Data.ParseCif),
@@ -44,6 +40,7 @@ export const DefaultPluginSpec: PluginSpec = {
         PluginSpec.Action(StateTransforms.Model.StructureSymmetryFromModel),
         PluginSpec.Action(StateTransforms.Model.StructureFromModel),
         PluginSpec.Action(StateTransforms.Model.ModelFromTrajectory),
+        PluginSpec.Action(StateTransforms.Model.UserStructureSelection),
         PluginSpec.Action(StateTransforms.Volume.VolumeFromCcp4),
         PluginSpec.Action(StateTransforms.Representation.StructureRepresentation3D),
         PluginSpec.Action(StateTransforms.Representation.StructureLabels3D),
@@ -71,19 +68,5 @@ export const DefaultPluginSpec: PluginSpec = {
 export function createPlugin(target: HTMLElement, spec?: PluginSpec): PluginContext {
     const ctx = new PluginContext(spec || DefaultPluginSpec);
     ReactDOM.render(React.createElement(Plugin, { plugin: ctx }), target);
-
-    trySetSnapshot(ctx);
-
     return ctx;
-}
-
-async function trySetSnapshot(ctx: PluginContext) {
-    try {
-        const snapshotUrl = getParam('snapshot-url', `[^&]+`);
-        if (!snapshotUrl) return;
-        await PluginCommands.State.Snapshots.Fetch.dispatch(ctx, { url: snapshotUrl })
-    } catch (e) {
-        ctx.log.error('Failed to load snapshot.');
-        console.warn('Failed to load snapshot', e);
-    }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/layout.ts b/src/mol-plugin/layout.ts
index 715a84be5cd68db524c193d3add84564c831636b..3be770d7036fa5db618f7c72d8bbf9f088d616e5 100644
--- a/src/mol-plugin/layout.ts
+++ b/src/mol-plugin/layout.ts
@@ -58,7 +58,7 @@ export class PluginLayout extends PluginComponent<PluginLayoutStateProps> {
     private rootState: RootState | undefined = void 0;
     private expandedViewport: HTMLMetaElement;
 
-    setProps(props: PluginLayoutStateProps) {
+    setProps(props: Partial<PluginLayoutStateProps>) {
         this.updateState(props);
     }
 
diff --git a/src/mol-plugin/skin/base/components/controls-base.scss b/src/mol-plugin/skin/base/components/controls-base.scss
index 4bd3f6c21b1314a4d4daefe728338d306b86e74c..6cc81d94774a45bc48c23e499b0e0daea47537e7 100644
--- a/src/mol-plugin/skin/base/components/controls-base.scss
+++ b/src/mol-plugin/skin/base/components/controls-base.scss
@@ -18,6 +18,15 @@
     text-align: center;
 }
 
+.msp-btn-icon-small {
+    height: $row-height;
+    width: 20px;
+    font-size: 85%;
+    line-height: $row-height;
+    padding: 0;
+    text-align: center;
+}
+
 .msp-btn-link {
     .msp-icon {
         font-size: 100%;
diff --git a/src/mol-plugin/skin/base/components/temp.scss b/src/mol-plugin/skin/base/components/temp.scss
index b94dd4262f4e875bddb170d27d32f7ab6807fa9b..cad3a2eb699cc3cd7c521873a05381751852ddcc 100644
--- a/src/mol-plugin/skin/base/components/temp.scss
+++ b/src/mol-plugin/skin/base/components/temp.scss
@@ -52,6 +52,12 @@
         > button:first-child {
             border-left: $control-spacing solid color-increase-contrast($default-background, 12%) !important;
         }
+
+        > div {
+            position: absolute;
+            right: 0;
+            top: 0;        
+        }
     }
 
     button {
@@ -60,13 +66,6 @@
     }
 }
 
-.msp-state-list-remove-button {
-    position: absolute;
-    right: 0;
-    top: 0;
-    width: $row-height;
-}
-
 .msp-tree-row {
     position: relative;
     height: $row-height;
diff --git a/src/mol-plugin/skin/base/icons.scss b/src/mol-plugin/skin/base/icons.scss
index 64502c7276d2d22746ea32c5390f6e880b15eeb8..8f35f30ba64d1d1722b8920aefeb5590d244b406 100644
--- a/src/mol-plugin/skin/base/icons.scss
+++ b/src/mol-plugin/skin/base/icons.scss
@@ -144,4 +144,44 @@
 
 .msp-icon-model-first:before {
     content: "\e89c";
+}
+
+.msp-icon-down-thin:before {
+	content: "\e88b";
+}
+
+.msp-icon-up-thin:before {
+	content: "\e88e";
+}
+
+.msp-icon-left-thin:before {
+	content: "\e88c";
+}
+
+.msp-icon-right-thin:before {
+	content: "\e88d";
+}
+
+.msp-icon-switch:before {
+	content: "\e896";
+}
+
+.msp-icon-play:before {
+	content: "\e897";
+}
+
+.msp-icon-stop:before {
+	content: "\e898";
+}
+
+.msp-icon-pause:before {
+	content: "\e899";
+}
+
+.msp-icon-left-open:before {
+	content: "\e87c";
+}
+
+.msp-icon-right-open:before {
+	content: "\e87d";
 }
\ No newline at end of file
diff --git a/src/mol-plugin/spec.ts b/src/mol-plugin/spec.ts
index 3a9611eb8d91f954dc718e7c177affaddbde2b48..6a8599df9b7870cd3b5d8b31de1b59ad89516ca5 100644
--- a/src/mol-plugin/spec.ts
+++ b/src/mol-plugin/spec.ts
@@ -17,7 +17,7 @@ interface PluginSpec {
     animations?: PluginStateAnimation[],
     customParamEditors?: [StateAction | StateTransformer, StateTransformParameters.Class][],
     layout?: {
-        initial?: PluginLayoutStateProps,
+        initial?: Partial<PluginLayoutStateProps>,
         controls?: {
             left?: React.ComponentClass | 'none',
             right?: React.ComponentClass | 'none',
diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts
index 28ca1986e8724f8482c4de17802479dd2fdd67e8..ff27f0b9994a052f3de8e99731566a972289352f 100644
--- a/src/mol-plugin/state.ts
+++ b/src/mol-plugin/state.ts
@@ -25,7 +25,7 @@ class PluginState {
     readonly behaviorState: State;
     readonly animation: PluginAnimationManager;
     readonly cameraSnapshots = new CameraSnapshotManager();
-    readonly snapshots = new PluginStateSnapshotManager();
+    readonly snapshots: PluginStateSnapshotManager;
 
     readonly behavior = {
         kind: this.ev.behavior<PluginState.Kind>('data'),
@@ -56,7 +56,8 @@ class PluginState {
             cameraSnapshots: p.cameraSnapshots ? this.cameraSnapshots.getStateSnapshot() : void 0,
             canvas3d: p.canvas3d ? {
                 props: this.plugin.canvas3d.props
-            } : void 0
+            } : void 0,
+            durationInMs: params && params.durationInMs
         };
     }
 
@@ -89,6 +90,7 @@ class PluginState {
     }
 
     constructor(private plugin: import('./context').PluginContext) {
+        this.snapshots = new PluginStateSnapshotManager(plugin);
         this.dataState = State.create(new SO.Root({ }), { globalContext: plugin });
         this.behaviorState = State.create(new PluginBehavior.Root({ }), { globalContext: plugin, rootProps: { isLocked: true } });
 
@@ -110,6 +112,7 @@ namespace PluginState {
 
     export type CameraTransitionStyle = 'instant' | 'animate'
     export const GetSnapshotParams = {
+        durationInMs: PD.Numeric(1500, { min: 100, max: 15000, step: 100 }, { label: 'Duration in ms' }),
         data: PD.Boolean(true),
         behavior: PD.Boolean(false),
         animation: PD.Boolean(true),
@@ -119,7 +122,7 @@ namespace PluginState {
         cameraSnapshots: PD.Boolean(false),
         cameraTranstionStyle: PD.Select<CameraTransitionStyle>('animate', [['animate', 'Animate'], ['instant', 'Instant']])
     };
-    export type GetSnapshotParams = Partial<PD.Value<typeof GetSnapshotParams>>
+    export type GetSnapshotParams = Partial<PD.Values<typeof GetSnapshotParams>>
     export const DefaultGetSnapshotParams = PD.getDefaultValues(GetSnapshotParams);
 
     export interface Snapshot {
@@ -134,6 +137,7 @@ namespace PluginState {
         cameraSnapshots?: CameraSnapshotManager.StateSnapshot,
         canvas3d?: {
             props?: Canvas3DProps
-        }
+        },
+        durationInMs?: number
     }
 }
diff --git a/src/mol-plugin/state/animation/manager.ts b/src/mol-plugin/state/animation/manager.ts
index 5e3cb09874eb55285ebbb7fd474f93f62357c5eb..4b9105afb18e8e22b57f41ad11a0c54dbe3416b4 100644
--- a/src/mol-plugin/state/animation/manager.ts
+++ b/src/mol-plugin/state/animation/manager.ts
@@ -91,6 +91,9 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
     start() {
         this.context.canvas3d.setSceneAnimating(true);
         this.updateState({ animationState: 'playing' });
+        if (!this.context.behaviors.state.isAnimating.value) {
+            this.context.behaviors.state.isAnimating.next(true);
+        }
         this.triggerUpdate();
 
         this._current.lastTime = 0;
@@ -103,6 +106,9 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat
     stop() {
         this.context.canvas3d.setSceneAnimating(false);
         if (typeof this._frame !== 'undefined') cancelAnimationFrame(this._frame);
+        if (this.context.behaviors.state.isAnimating.value) {
+            this.context.behaviors.state.isAnimating.next(false);
+        }
 
         if (this.state.animationState !== 'stopped') {
             this.updateState({ animationState: 'stopped' });
diff --git a/src/mol-plugin/state/snapshots.ts b/src/mol-plugin/state/snapshots.ts
index 1648eb7850e965b80410e0fb7c07c43eb8ec61b5..e30e142ef29bb4d054b770cd91a545167d68dcbb 100644
--- a/src/mol-plugin/state/snapshots.ts
+++ b/src/mol-plugin/state/snapshots.ts
@@ -4,39 +4,95 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { OrderedMap } from 'immutable';
+import { List } from 'immutable';
 import { UUID } from 'mol-util';
 import { PluginState } from '../state';
 import { PluginComponent } from 'mol-plugin/component';
+import { PluginContext } from 'mol-plugin/context';
 
 export { PluginStateSnapshotManager }
 
-class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | undefined, entries: OrderedMap<string, PluginStateSnapshotManager.Entry> }> {
+class PluginStateSnapshotManager extends PluginComponent<{
+    current?: UUID | undefined,
+    entries: List<PluginStateSnapshotManager.Entry>,
+    isPlaying: boolean,
+    nextSnapshotDelayInMs: number
+}> {
+    static DefaultNextSnapshotDelayInMs = 1500;
+
+    private entryMap = new Map<string, PluginStateSnapshotManager.Entry>();
+
     readonly events = {
         changed: this.ev()
     };
 
-    getEntry(id: string) {
-        return this.state.entries.get(id);
+    currentGetSnapshotParams: PluginState.GetSnapshotParams = PluginState.DefaultGetSnapshotParams as any;
+
+    getIndex(e: PluginStateSnapshotManager.Entry) {
+        return this.state.entries.indexOf(e);
+    }
+
+    getEntry(id: string | undefined) {
+        if (!id) return;
+        return this.entryMap.get(id);
     }
 
     remove(id: string) {
-        if (!this.state.entries.has(id)) return;
+        const e = this.entryMap.get(id);
+        if (!e) return;
+
+        this.entryMap.delete(id);
         this.updateState({
             current: this.state.current === id ? void 0 : this.state.current,
-            entries: this.state.entries.delete(id)
+            entries: this.state.entries.delete(this.getIndex(e))
         });
         this.events.changed.next();
     }
 
     add(e: PluginStateSnapshotManager.Entry) {
-        this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(e.snapshot.id, e) });
+        this.entryMap.set(e.snapshot.id, e);
+        this.updateState({ current: e.snapshot.id, entries: this.state.entries.push(e) });
+        this.events.changed.next();
+    }
+
+    replace(id: string, snapshot: PluginState.Snapshot) {
+        const old = this.getEntry(id);
+        if (!old) return;
+
+        const idx = this.getIndex(old);
+        // The id changes here!
+        const e = PluginStateSnapshotManager.Entry(snapshot, {
+            name: old.name,
+            description: old.description
+        });
+        this.entryMap.set(snapshot.id, e);
+        this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(idx, e) });
+        this.events.changed.next();
+    }
+
+    move(id: string, dir: -1 | 1) {
+        const len = this.state.entries.size;
+        if (len < 2) return;
+
+        const e = this.getEntry(id);
+        if (!e) return;
+        const from = this.getIndex(e);
+        let to = (from + dir) % len;
+        if (to < 0) to += len;
+        const f = this.state.entries.get(to);
+
+        const entries = this.state.entries.asMutable();
+        entries.set(to, e);
+        entries.set(from, f);
+
+        this.updateState({ current: e.snapshot.id, entries: entries.asImmutable() });
         this.events.changed.next();
     }
 
     clear() {
         if (this.state.entries.size === 0) return;
-        this.updateState({ current: void 0, entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() });
+        this.entryMap.clear();
+        this.updateState({ current: void 0, entries: List<PluginStateSnapshotManager.Entry>() });
         this.events.changed.next();
     }
 
@@ -50,39 +106,50 @@ class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | unde
     }
 
     getNextId(id: string | undefined, dir: -1 | 1) {
-        const xs = this.state.entries;
-        const keys = xs.keys();
-        let k = keys.next();
-        let prev = k.value;
-        const fst = prev;
-        while (!k.done) {
-            k = keys.next();
-            if (k.value === id && dir === -1) return prev;
-            if (!k.done && prev === id && dir === 1) return k.value;
-            if (!k.done) prev = k.value;
-            else break;
+        const len = this.state.entries.size;
+        if (!id) {
+            if (len === 0) return void 0;
+            const idx = dir === -1 ? len - 1 : 0;
+            return this.state.entries.get(idx).snapshot.id;
         }
-        if (dir === -1) return prev;
-        return fst;
+
+        const e = this.getEntry(id);
+        if (!e) return;
+        let idx = this.getIndex(e);
+        if (idx < 0) return;
+
+        idx = (idx + dir) % len;
+        if (idx < 0) idx += len;
+
+        return this.state.entries.get(idx).snapshot.id;
     }
 
-    setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): PluginState.Snapshot | undefined {
+    async setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): Promise<PluginState.Snapshot | undefined> {
         this.clear();
-        const entries = this.state.entries.withMutations(m => {
-            for (const e of snapshot.entries) {
-                m.set(e.snapshot.id, e);
-            }
-        });
+        const entries = List<PluginStateSnapshotManager.Entry>().asMutable()
+        for (const e of snapshot.entries) {
+            this.entryMap.set(e.snapshot.id, e);
+            entries.push(e);
+        }
         const current = snapshot.current
             ? snapshot.current
             : snapshot.entries.length > 0
             ? snapshot.entries[0].snapshot.id
             : void 0;
-        this.updateState({ current, entries });
+        this.updateState({
+            current,
+            entries: entries.asImmutable(),
+            isPlaying: false,
+            nextSnapshotDelayInMs: snapshot.playback ? snapshot.playback.nextSnapshotDelayInMs : PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs
+        });
         this.events.changed.next();
         if (!current) return;
-        const ret = this.getEntry(current);
-        return ret && ret.snapshot;
+        const entry = this.getEntry(current);
+        const next = entry && entry.snapshot;
+        if (!next) return;
+        await this.plugin.state.setSnapshot(next);
+        if (snapshot.playback &&  snapshot.playback.isPlaying) this.play();
+        return next;
     }
 
     getRemoteSnapshot(options?: { name?: string, description?: string }): PluginStateSnapshotManager.RemoteSnapshot {
@@ -92,12 +159,53 @@ class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | unde
             name: options && options.name,
             description: options && options.description,
             current: this.state.current,
+            playback: {
+                isPlaying: this.state.isPlaying,
+                nextSnapshotDelayInMs: this.state.nextSnapshotDelayInMs
+            },
             entries: this.state.entries.valueSeq().toArray()
         };
     }
 
-    constructor() {
-        super({ current: void 0, entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() });
+    private timeoutHandle: any = void 0;
+    private next = async () => {
+        this.timeoutHandle = void 0;
+        const next = this.getNextId(this.state.current, 1);
+        if (!next || next === this.state.current) {
+            this.stop();
+            return;
+        }
+        const snapshot = this.setCurrent(next)!;
+        await this.plugin.state.setSnapshot(snapshot);
+        const delay = typeof snapshot.durationInMs !== 'undefined' ? snapshot.durationInMs : this.state.nextSnapshotDelayInMs;
+        this.timeoutHandle = setTimeout(this.next, delay);
+    };
+
+    play() {
+        this.updateState({ isPlaying: true });
+        this.next();
+    }
+
+    stop() {
+        this.updateState({ isPlaying: false });
+        if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle);
+        this.timeoutHandle = void 0;
+        this.events.changed.next();
+    }
+
+    togglePlay() {
+        if (this.state.isPlaying) this.stop();
+        else this.play();
+    }
+
+    constructor(private plugin: PluginContext) {
+        super({
+            current: void 0,
+            entries: List(),
+            isPlaying: false,
+            nextSnapshotDelayInMs: PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs
+        });
+        // TODO make nextSnapshotDelayInMs editable
     }
 }
 
@@ -109,8 +217,8 @@ namespace PluginStateSnapshotManager {
         snapshot: PluginState.Snapshot
     }
 
-    export function Entry(snapshot: PluginState.Snapshot, name?: string, description?: string): Entry {
-        return { timestamp: +new Date(), name, snapshot, description };
+    export function Entry(snapshot: PluginState.Snapshot, params: {name?: string, description?: string }): Entry {
+        return { timestamp: +new Date(), snapshot, ...params };
     }
 
     export interface RemoteSnapshot {
@@ -118,6 +226,10 @@ namespace PluginStateSnapshotManager {
         name?: string,
         description?: string,
         current: UUID | undefined,
+        playback: {
+            isPlaying: boolean,
+            nextSnapshotDelayInMs: number,
+        },
         entries: Entry[]
     }
 }
\ No newline at end of file
diff --git a/src/mol-plugin/state/transforms/model.ts b/src/mol-plugin/state/transforms/model.ts
index fd46f04fad369b6cb8eee92d9eb65d9b268f0069..dd52f03e21207603c8123c540b2ef209939be7f2 100644
--- a/src/mol-plugin/state/transforms/model.ts
+++ b/src/mol-plugin/state/transforms/model.ts
@@ -22,6 +22,8 @@ import { stringToWords } from 'mol-util/string';
 import { PluginStateObject as SO, PluginStateTransform } from '../objects';
 import { trajectoryFromGRO } from 'mol-model-formats/structure/gro';
 import { parseGRO } from 'mol-io/reader/gro/parser';
+import { parseMolScript } from 'mol-script/language/parser';
+import { transpileMolScript } from 'mol-script/script/mol-script/symbols';
 
 export { TrajectoryFromMmCif };
 export { TrajectoryFromPDB };
@@ -31,8 +33,10 @@ export { StructureFromModel };
 export { StructureAssemblyFromModel };
 export { StructureSymmetryFromModel };
 export { StructureSelection };
+export { UserStructureSelection };
 export { StructureComplexElement };
 export { CustomModelProperties };
+
 type TrajectoryFromMmCif = typeof TrajectoryFromMmCif
 const TrajectoryFromMmCif = PluginStateTransform.BuiltIn({
     name: 'trajectory-from-mmcif',
@@ -243,6 +247,31 @@ const StructureSelection = PluginStateTransform.BuiltIn({
     }
 });
 
+type UserStructureSelection = typeof UserStructureSelection
+const UserStructureSelection = PluginStateTransform.BuiltIn({
+    name: 'user-structure-selection',
+    display: { name: 'Structure Selection', description: 'Create a molecular structure from the specified query expression.' },
+    from: SO.Molecule.Structure,
+    to: SO.Molecule.Structure,
+    params: {
+        query: PD.ScriptExpression({ language: 'mol-script', expression: '(sel.atom.atom-groups :residue-test (= atom.resname ALA))' }),
+        label: PD.makeOptional(PD.Text(''))
+    }
+})({
+    apply({ a, params }) {
+        // TODO: use cache, add "update"
+        const parsed = parseMolScript(params.query.expression);
+        if (parsed.length === 0) throw new Error('No query');
+        const query = transpileMolScript(parsed[0]);
+        const compiled = compile<Sel>(query);
+        const result = compiled(new QueryContext(a.data));
+        const s = Sel.unionStructure(result);
+        if (s.elementCount === 0) return StateObject.Null;
+        const props = { label: `${params.label || 'Selection'}`, description: structureDesc(s) };
+        return new SO.Molecule.Structure(s, props);
+    }
+});
+
 namespace StructureComplexElement {
     export type Types = 'atomic-sequence' | 'water' | 'atomic-het' | 'spheres'
 }
diff --git a/src/mol-plugin/state/transforms/representation.ts b/src/mol-plugin/state/transforms/representation.ts
index 08b0c3275ad5fa487fdd5e818217edd6b3f99d47..6b8d31f51c850c5d7ead6ee5cdabfbf2b523c699 100644
--- a/src/mol-plugin/state/transforms/representation.ts
+++ b/src/mol-plugin/state/transforms/representation.ts
@@ -194,7 +194,12 @@ const StructureLabels3D = PluginStateTransform.BuiltIn({
     to: SO.Molecule.Representation3D,
     params: {
         // TODO: other targets
-        target: PD.Select<'elements' | 'residues'>('residues', [['residues', 'Residues'], ['elements', 'Elements']]),
+        target: PD.MappedStatic('residues', {
+            'elements': PD.Group({ }),
+            'residues': PD.Group({ }),
+            'static-text': PD.Group({ value: PD.Text('') }, { isFlat: true })
+        }),
+         // PD.Select<'elements' | 'residues'>('residues', [['residues', 'Residues'], ['elements', 'Elements']]),
         options: PD.Group({
             ...Text.Params,
 
@@ -212,7 +217,7 @@ const StructureLabels3D = PluginStateTransform.BuiltIn({
     apply({ a, params }) {
         return Task.create('Structure Labels', async ctx => {
             const repr = await getLabelRepresentation(ctx, a.data, params);
-            return new SO.Molecule.Representation3D(repr, { label: `Labels`, description: params.target });
+            return new SO.Molecule.Representation3D(repr, { label: `Labels`, description: params.target.name });
         });
     },
     update({ a, b, newParams }) {
diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx
index 847b7b215264217b8cef2dd802fd5d5dce376e7a..fba79579b48fe8838daad958088053925973aee3 100644
--- a/src/mol-plugin/ui/controls.tsx
+++ b/src/mol-plugin/ui/controls.tsx
@@ -56,6 +56,7 @@ export class TrajectoryControls extends PluginUIComponent<{}, { show: boolean, l
 
     componentDidMount() {
         this.subscribe(this.plugin.state.dataState.events.changed, this.update);
+        this.subscribe(this.plugin.behaviors.state.isAnimating, this.update);
     }
 
     reset = () => PluginCommands.State.ApplyAction.dispatch(this.plugin, {
@@ -76,21 +77,25 @@ export class TrajectoryControls extends PluginUIComponent<{}, { show: boolean, l
     render() {
         if (!this.state.show) return null;
 
+        const isAnimating = this.plugin.behaviors.state.isAnimating.value;
+
         return <div className='msp-traj-controls'>
-            <IconButton icon='model-first' title='First Model' onClick={this.reset} />
-            <IconButton icon='model-prev' title='Previous Model' onClick={this.prev} />
-            <IconButton icon='model-next' title='Next Model' onClick={this.next} />
+            <IconButton icon='model-first' title='First Model' onClick={this.reset} disabled={isAnimating} />
+            <IconButton icon='model-prev' title='Previous Model' onClick={this.prev} disabled={isAnimating} />
+            <IconButton icon='model-next' title='Next Model' onClick={this.next} disabled={isAnimating} />
             { !!this.state.label && <span>{this.state.label}</span> }
         </div>;
     }
 }
 
-export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean }> {
-    state = { isBusy: false }
+export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean, show: boolean }> {
+    state = { isBusy: false, show: true }
 
     componentDidMount() {
         // TODO: this needs to be diabled when the state is updating!
         this.subscribe(this.plugin.state.snapshots.events.changed, () => this.forceUpdate());
+        this.subscribe(this.plugin.behaviors.state.isUpdating, isBusy => this.setState({ isBusy }));
+        this.subscribe(this.plugin.behaviors.state.isAnimating, isAnimating => this.setState({ show: !isAnimating }));
     }
 
     async update(id: string) {
@@ -104,35 +109,43 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus
         this.update(e.target.value);
     }
 
-    prev =  () => {
+    prev = () => {
         const s = this.plugin.state.snapshots;
         const id = s.getNextId(s.state.current, -1);
         if (id) this.update(id);
     }
 
-    next =  () => {
+    next = () => {
         const s = this.plugin.state.snapshots;
         const id = s.getNextId(s.state.current, 1);
         if (id) this.update(id);
     }
 
+    togglePlay = () => {
+        this.plugin.state.snapshots.togglePlay();
+    }
+
     render() {
         const snapshots = this.plugin.state.snapshots;
         const count = snapshots.state.entries.size;
 
-        if (count < 2) {
+        if (count < 2 || !this.state.show) {
             return null;
         }
 
         const current = snapshots.state.current;
+        const isPlaying = snapshots.state.isPlaying;
+
+        // TODO: better handle disabled state
 
         return <div className='msp-state-snapshot-viewport-controls'>
-            <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy}>
+            <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy || isPlaying}>
                 {!current && <option key='none' value='none'></option>}
                 {snapshots.state.entries.valueSeq().map((e, i) => <option key={e!.snapshot.id} value={e!.snapshot.id}>{`[${i! + 1}/${count}]`} {e!.name || new Date(e!.timestamp).toLocaleString()}</option>)}
             </select>
-            <IconButton icon='model-prev' title='Previous State' onClick={this.prev} disabled={this.state.isBusy} />
-            <IconButton icon='model-next' title='Next State' onClick={this.next} disabled={this.state.isBusy} />
+            <IconButton icon='left-open' title='Previous State' onClick={this.prev} disabled={this.state.isBusy || isPlaying} />
+            <IconButton icon='right-open' title='Next State' onClick={this.next} disabled={this.state.isBusy || isPlaying} />
+            <IconButton icon={isPlaying ? 'pause' : 'play'} title={isPlaying ? 'Pause' : 'Play'} onClick={this.togglePlay} />
         </div>;
     }
 }
diff --git a/src/mol-plugin/ui/controls/common.tsx b/src/mol-plugin/ui/controls/common.tsx
index 423b0784c0abee51a9d521161e2ea4759d81dca4..7bbb92d9156ebbf3648cd000a5d4130c07b4b00b 100644
--- a/src/mol-plugin/ui/controls/common.tsx
+++ b/src/mol-plugin/ui/controls/common.tsx
@@ -81,10 +81,18 @@ export class NumericInput extends React.PureComponent<{
     }
 }
 
-export function IconButton(props: { icon: string, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title?: string, toggleState?: boolean, disabled?: boolean }) {
-    let className = `msp-btn msp-btn-link msp-btn-icon`;
+export function IconButton(props: {
+    icon: string,
+    isSmall?: boolean,
+    onClick: (e: React.MouseEvent<HTMLButtonElement>) => void,
+    title?: string,
+    toggleState?: boolean,
+    disabled?: boolean,
+    'data-id'?: string
+}) {
+    let className = `msp-btn msp-btn-link msp-btn-icon${props.isSmall ? '-small' : ''}`;
     if (typeof props.toggleState !== 'undefined') className += ` msp-btn-link-toggle-${props.toggleState ? 'on' : 'off'}`
-    return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled}>
+    return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled} data-id={props['data-id']}>
         <span className={`msp-icon msp-icon-${props.icon}`}/>
     </button>;
 }
diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx
index ed0baaa35f518ad4dcf46f6b0c8f4b02d84ea619..c09203ccdf7e27ca81bbe27594b0ac10518e95cb 100644
--- a/src/mol-plugin/ui/controls/parameters.tsx
+++ b/src/mol-plugin/ui/controls/parameters.tsx
@@ -63,6 +63,7 @@ function controlFor(param: PD.Any): ParamControl | undefined {
         case 'group': return GroupControl;
         case 'mapped': return MappedControl;
         case 'line-graph': return LineGraphControl;
+        case 'script-expression': return ScriptExpressionControl;
         default:
             const _: never = param;
             console.warn(`${_} has no associated UI component`);
@@ -77,7 +78,7 @@ export interface ParamProps<P extends PD.Base<any> = PD.Base<any>> { name: strin
 export type ParamControl = React.ComponentClass<ParamProps<any>>
 
 export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<ParamProps<P>> {
-    protected update(value: any) {
+    protected update(value: P['defaultValue']) {
         this.props.onChange({ param: this.props.param, name: this.props.name, value });
     }
 
@@ -562,3 +563,32 @@ export class ConvertedControl extends React.PureComponent<ParamProps<PD.Converte
         return <Converted param={this.props.param.converted} value={value} name={this.props.name} onChange={this.onChange} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />
     }
 }
+
+export class ScriptExpressionControl extends SimpleParam<PD.ScriptExpression> {
+    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        const value = e.target.value;
+        if (value !== this.props.value.expression) {
+            this.update({ language: this.props.value.language, expression: value });
+        }
+    }
+
+    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+        if (!this.props.onEnter) return;
+        if ((e.keyCode === 13 || e.charCode === 13)) {
+            this.props.onEnter();
+        }
+    }
+
+    renderControl() {
+        // TODO: improve!
+
+        const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
+        return <input type='text'
+            value={this.props.value.expression || ''}
+            placeholder={placeholder}
+            onChange={this.onChange}
+            onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
+            disabled={this.props.isDisabled}
+        />;
+    }
+}
\ No newline at end of file
diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx
index 132d5ea2c7c3343949f894cb25c22daca4ac67b5..d28f75d7ad9570f0a3ab740f08e57ccf616d4ca1 100644
--- a/src/mol-plugin/ui/state.tsx
+++ b/src/mol-plugin/ui/state.tsx
@@ -13,24 +13,26 @@ import { ParameterControls } from './controls/parameters';
 import { ParamDefinition as PD} from 'mol-util/param-definition';
 import { PluginState } from 'mol-plugin/state';
 import { urlCombine } from 'mol-util/url';
+import { IconButton } from './controls/common';
+import { formatTimespan } from 'mol-util/now';
 
 export class StateSnapshots extends PluginUIComponent<{ }> {
 
     render() {
         return <div>
             <div className='msp-section-header'>State</div>
-            <StateSnapshotControls />
+            <LocalStateSnapshots />
             <LocalStateSnapshotList />
             <RemoteStateSnapshots />
         </div>;
     }
 }
 
-class StateSnapshotControls extends PluginUIComponent<
+class LocalStateSnapshots extends PluginUIComponent<
     { },
-    { params: PD.Values<typeof StateSnapshotControls.Params> }> {
+    { params: PD.Values<typeof LocalStateSnapshots.Params> }> {
 
-    state = { params: PD.getDefaultValues(StateSnapshotControls.Params) };
+    state = { params: PD.getDefaultValues(LocalStateSnapshots.Params) };
 
     static Params = {
         name: PD.Text(),
@@ -41,7 +43,11 @@ class StateSnapshotControls extends PluginUIComponent<
     };
 
     add = () => {
-        PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
+        PluginCommands.State.Snapshots.Add.dispatch(this.plugin, {
+            name: this.state.params.name,
+            description: this.state.params.options.description,
+            params: this.state.params.options
+        });
         this.setState({
             params: {
                 name: '',
@@ -81,8 +87,10 @@ class StateSnapshotControls extends PluginUIComponent<
                 </div>
             </div>
 
-            <ParameterControls params={StateSnapshotControls.Params} values={this.state.params} onEnter={this.add} onChange={p => {
-                this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
+            <ParameterControls params={LocalStateSnapshots.Params} values={this.state.params} onEnter={this.add} onChange={p => {
+                const params = { ...this.state.params, [p.name]: p.value };
+                this.setState({ params } as any);
+                this.plugin.state.snapshots.currentGetSnapshotParams = params.options;
             }}/>
 
             <div className='msp-btn-row-group'>
@@ -99,26 +107,52 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
         this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
     }
 
-    apply(id: string) {
-        return () => PluginCommands.State.Snapshots.Apply.dispatch(this.plugin, { id });
+    apply = (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        PluginCommands.State.Snapshots.Apply.dispatch(this.plugin, { id });
     }
 
-    remove(id: string) {
-        return () => {
-            PluginCommands.State.Snapshots.Remove.dispatch(this.plugin, { id });
-        }
+    remove = (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        PluginCommands.State.Snapshots.Remove.dispatch(this.plugin, { id });
+    }
+
+    moveUp = (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        PluginCommands.State.Snapshots.Move.dispatch(this.plugin, { id, dir: -1 });
+    }
+
+    moveDown = (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        PluginCommands.State.Snapshots.Move.dispatch(this.plugin, { id, dir: 1 });
+    }
+
+    replace = (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        PluginCommands.State.Snapshots.Replace.dispatch(this.plugin, { id, params: this.plugin.state.snapshots.currentGetSnapshotParams });
     }
 
     render() {
         const current = this.plugin.state.snapshots.state.current;
         return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
-            {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}>
-                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}>
-                    <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>{e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>{e!.description}</small>
-                </button>
-                <button onClick={this.remove(e!.snapshot.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
-                    <span className='msp-icon msp-icon-remove' />
+            {this.plugin.state.snapshots.state.entries.map(e => <li key={e!.snapshot.id}>
+                <button data-id={e!.snapshot.id} className='msp-btn msp-btn-block msp-form-control' onClick={this.apply}>
+                    <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>
+                        {e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>
+                        {`${e!.snapshot.durationInMs ? formatTimespan(e!.snapshot.durationInMs, false) + `${e!.description ? ', ' : ''}` : ''}${e!.description ? e!.description : ''}`}
+                    </small>
                 </button>
+                <div>
+                    <IconButton data-id={e!.snapshot.id} icon='up-thin' title='Move Up' onClick={this.moveUp} isSmall={true} />
+                    <IconButton data-id={e!.snapshot.id} icon='down-thin' title='Move Down' onClick={this.moveDown} isSmall={true} />
+                    <IconButton data-id={e!.snapshot.id} icon='switch' title='Replace' onClick={this.replace} isSmall={true} />
+                    <IconButton data-id={e!.snapshot.id} icon='remove' title='Remove' onClick={this.remove} isSmall={true} />
+                </div>
             </li>)}
         </ul>;
     }
@@ -172,7 +206,11 @@ class RemoteStateSnapshots extends PluginUIComponent<
     upload = async () => {
         this.setState({ isBusy: true });
         if (this.plugin.state.snapshots.state.entries.size === 0) {
-            await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
+            await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, {
+                name: this.state.params.name,
+                description: this.state.params.options.description,
+                params: this.plugin.state.snapshots.currentGetSnapshotParams
+            });
         }
 
         await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, {
@@ -254,9 +292,9 @@ class RemoteStateSnapshotList extends PurePluginUIComponent<
                     disabled={this.props.isBusy} onContextMenu={this.open} title='Click to download, right-click to open in a new tab.'>
                     {e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small>
                 </button>
-                <button data-id={e!.id} onClick={this.props.remove} className='msp-btn msp-btn-link msp-state-list-remove-button' disabled={this.props.isBusy}>
-                    <span className='msp-icon msp-icon-remove' />
-                </button>
+                <div>
+                    <IconButton data-id={e!.id} icon='remove' title='Remove' onClick={this.props.remove} disabled={this.props.isBusy} />
+                </div>
             </li>)}
         </ul>;
     }
diff --git a/src/mol-plugin/util/structure-labels.ts b/src/mol-plugin/util/structure-labels.ts
index 7bdb7bce0af682291d332c55f15378cfc9e5e282..e2057a51e522d6a499f96492357f962847ac4bd2 100644
--- a/src/mol-plugin/util/structure-labels.ts
+++ b/src/mol-plugin/util/structure-labels.ts
@@ -36,7 +36,7 @@ function getLabelsText(data: LabelsData, props: PD.Values<Text.Params>, text?: T
 
 export async function getLabelRepresentation(ctx: RuntimeContext, structure: Structure, params: StateTransformer.Params<StructureLabels3D>, prev?: ShapeRepresentation<LabelsData, Text, Text.Params>) {
     const repr = prev || ShapeRepresentation(getLabelsShape, Text.Utils);
-    const data = getLabelData(structure, params.target);
+    const data = getLabelData(structure, params);
     await repr.createOrUpdate(params.options, data).runInContext(ctx);
     return repr;
 }
@@ -47,7 +47,26 @@ function getLabelsShape(ctx: RuntimeContext, data: LabelsData, props: PD.Values<
 }
 
 const boundaryHelper = new BoundaryHelper();
-function getLabelData(structure: Structure, level: 'elements' | 'residues'): LabelsData {
+function getLabelData(structure: Structure, params: StateTransformer.Params<StructureLabels3D>): LabelsData {
+    if (params.target.name === 'static-text') {
+        return getLabelDataStatic(structure, params.target.params.value);
+    } else {
+        return getLabelDataComputed(structure, params.target.name);
+    }
+
+}
+
+function getLabelDataStatic(structure: Structure, text: string): LabelsData {
+    const boundary = structure.boundary.sphere;
+    return {
+        texts: [text],
+        positions: [boundary.center],
+        sizes: [1],
+        depths: [boundary.radius]
+    };
+}
+
+function getLabelDataComputed(structure: Structure, level: 'elements' | 'residues'): LabelsData {
     const data: LabelsData = { texts: [], positions: [], sizes: [], depths: [] };
 
     const l = StructureElement.create();
diff --git a/src/mol-script/language/symbol-table/structure-query.ts b/src/mol-script/language/symbol-table/structure-query.ts
index 1d87ccf8a62a6b4abd7ee496664acb2210752bbc..4f9f7377efb220f0dbb65717677675540095a9d1 100644
--- a/src/mol-script/language/symbol-table/structure-query.ts
+++ b/src/mol-script/language/symbol-table/structure-query.ts
@@ -249,6 +249,7 @@ const atomProperty = {
         }), Type.Num, 'Number of bonds (by default only covalent bonds are counted).'),
 
         sourceIndex: atomProp(Type.Num, 'Index of the atom/element in the input file.'),
+        operatorName: atomProp(Type.Str, 'Name of the symmetry operator applied to this element.'),
     },
 
     topology: {
diff --git a/src/mol-script/runtime/query/table.ts b/src/mol-script/runtime/query/table.ts
index 7e48327940d58dd3913730897b2a514eb9a566eb..ba1072a587beb9c0c8ed2bf3b83dac130894b7f8 100644
--- a/src/mol-script/runtime/query/table.ts
+++ b/src/mol-script/runtime/query/table.ts
@@ -206,9 +206,14 @@ const symbols = [
         elementRadius: xs['atom-radius']
     })(ctx)),
     D(MolScript.structureQuery.modifier.wholeResidues, (ctx, xs) => Queries.modifiers.wholeResidues(xs[0] as any)(ctx)),
+    D(MolScript.structureQuery.modifier.union, (ctx, xs) => Queries.modifiers.union(xs[0] as any)(ctx)),
     D(MolScript.structureQuery.modifier.expandProperty, (ctx, xs) => Queries.modifiers.expandProperty(xs[0] as any, xs['property'])(ctx)),
     D(MolScript.structureQuery.modifier.exceptBy, (ctx, xs) => Queries.modifiers.exceptBy(xs[0] as any, xs['by'] as any)(ctx)),
 
+    // ============= COMBINATORS ================
+
+    D(MolScript.structureQuery.combinator.merge, (ctx, xs) => Queries.combinators.merge(xs as any)(ctx)),
+
     // ============= ATOM PROPERTIES ================
 
     // ~~~ CORE ~~~
@@ -220,6 +225,7 @@ const symbols = [
     D(MolScript.structureQuery.atomProperty.core.y, atomProp(StructureProperties.atom.y)),
     D(MolScript.structureQuery.atomProperty.core.z, atomProp(StructureProperties.atom.z)),
     D(MolScript.structureQuery.atomProperty.core.sourceIndex, atomProp(StructureProperties.atom.sourceIndex)),
+    D(MolScript.structureQuery.atomProperty.core.operatorName, atomProp(StructureProperties.unit.operator_name)),
     D(MolScript.structureQuery.atomProperty.core.atomKey, (ctx, _) => cantorPairing(ctx.element.unit.id, ctx.element.element)),
 
     // TODO:
diff --git a/src/mol-script/script/mol-script/symbols.ts b/src/mol-script/script/mol-script/symbols.ts
index 43aa954f111bcb83f046ecfb26eec59eaf2b88c6..0c4826f7ffc685e85a84b9617b83188def6a6e06 100644
--- a/src/mol-script/script/mol-script/symbols.ts
+++ b/src/mol-script/script/mol-script/symbols.ts
@@ -7,7 +7,6 @@
 import { UniqueArray } from 'mol-data/generic';
 import Expression from '../../language/expression';
 import { Argument, MSymbol } from '../../language/symbol';
-//import * as M from './macro'
 import { MolScriptSymbolTable as MolScript } from '../../language/symbol-table';
 import Type from '../../language/type';
 
@@ -197,6 +196,7 @@ export const SymbolTable = [
             Alias(MolScript.structureQuery.atomProperty.core.y, 'atom.y'),
             Alias(MolScript.structureQuery.atomProperty.core.z, 'atom.z'),
             Alias(MolScript.structureQuery.atomProperty.core.sourceIndex, 'atom.src-index'),
+            Alias(MolScript.structureQuery.atomProperty.core.operatorName, 'atom.op-name'),
             Alias(MolScript.structureQuery.atomProperty.core.atomKey, 'atom.key'),
             Alias(MolScript.structureQuery.atomProperty.core.bondCount, 'atom.bond-count'),
 
@@ -343,4 +343,4 @@ export function transpileMolScript(expr: Expression) {
 //     if (a.length === b.length) return (a < b) as any;
 //     return a.length - b.length;
 // });
-//export default [...sortedSymbols, ...NamedArgs.map(a => ':' + a), ...Constants];
\ No newline at end of file
+// export default [...sortedSymbols, ...NamedArgs.map(a => ':' + a), ...Constants];
\ No newline at end of file
diff --git a/src/mol-state/state.ts b/src/mol-state/state.ts
index 129969ad06fc7bd42e7c587ce4204f8fb36073ef..4da90c9051973657063640f83dd9143e380d5924 100644
--- a/src/mol-state/state.ts
+++ b/src/mol-state/state.ts
@@ -42,7 +42,8 @@ class State {
             removed: this.ev<State.ObjectEvent & { obj?: StateObject }>()
         },
         log: this.ev<LogEntry>(),
-        changed: this.ev<void>()
+        changed: this.ev<void>(),
+        isUpdating: this.ev<boolean>()
     };
 
     readonly behaviors = {
@@ -130,6 +131,7 @@ class State {
     updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<void>
     updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<any> {
         return Task.create('Update Tree', async taskCtx => {
+            this.events.isUpdating.next(true);
             let updated = false;
             const ctx = this.updateTreeAndCreateCtx(tree, taskCtx, options);
             try {
@@ -140,6 +142,7 @@ class State {
                 }
             } finally {
                 if (updated) this.events.changed.next();
+                this.events.isUpdating.next(false);
 
                 for (const ref of ctx.stateChanges) {
                     this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) });
diff --git a/src/mol-util/now.ts b/src/mol-util/now.ts
index d2d1037af7648a5af6fd8afbe22e27c001640c13..7a88af574b4b70147b5d99127c19d46abbd07ffb 100644
--- a/src/mol-util/now.ts
+++ b/src/mol-util/now.ts
@@ -28,7 +28,7 @@ namespace now {
 }
 
 
-function formatTimespan(t: number) {
+function formatTimespan(t: number, includeMsZeroes = true) {
     if (isNaN(t)) return 'n/a';
 
     let h = Math.floor(t / (60 * 60 * 1000)),
@@ -37,6 +37,7 @@ function formatTimespan(t: number) {
         ms = Math.floor(t % 1000).toString();
 
     while (ms.length < 3) ms = '0' + ms;
+    while (!includeMsZeroes && ms.length > 1 && ms[ms.length - 1] === '0') ms = ms.substr(0, ms.length - 1);
 
     if (h > 0) return `${h}h${m}m${s}.${ms}s`;
     if (m > 0) return `${m}m${s}.${ms}s`;
diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts
index 46d8abbfeb457cfbb5ae83f320374cb77cd266e2..3bb4e3ed84140937872cf12da198e9c63ed3501f 100644
--- a/src/mol-util/param-definition.ts
+++ b/src/mol-util/param-definition.ts
@@ -213,7 +213,14 @@ export namespace ParamDefinition {
         return { type: 'conditioned', select: Select<string>(conditionForValue(defaultValue) as string, options), defaultValue, conditionParams, conditionForValue, conditionedValue };
     }
 
-    export type Any = Value<any> | Select<any> | MultiSelect<any> | Boolean | Text | Color | Vec3 | Numeric | FileParam | Interval | LineGraph | ColorScale<any> | Group<any> | Mapped<any> | Converted<any, any> | Conditioned<any, any, any>
+    export interface ScriptExpression extends Base<{ language: 'mol-script', expression: string }> {
+        type: 'script-expression'
+    }
+    export function ScriptExpression(defaultValue: ScriptExpression['defaultValue'], info?: Info): ScriptExpression {
+        return setInfo<ScriptExpression>({ type: 'script-expression', defaultValue }, info)
+    }
+
+    export type Any = Value<any> | Select<any> | MultiSelect<any> | Boolean | Text | Color | Vec3 | Numeric | FileParam | Interval | LineGraph | ColorScale<any> | Group<any> | Mapped<any> | Converted<any, any> | Conditioned<any, any, any> | ScriptExpression
 
     export type Params = { [k: string]: Any }
     export type Values<T extends Params> = { [k in keyof T]: T[k]['defaultValue'] }
@@ -301,6 +308,9 @@ export namespace ParamDefinition {
             return true;
         } else if (p.type === 'vec3') {
             return Vec3Data.equals(a, b);
+        } else if (p.type === 'script-expression') {
+            const u = a as ScriptExpression['defaultValue'], v = b as ScriptExpression['defaultValue'];
+            return u.language === v.language && u.expression === v.expression;
         } else if (typeof a === 'object' && typeof b === 'object') {
             return shallowEqual(a, b);
         }