diff --git a/main.ts b/main.ts
index b35da4a..18b7f4d 100644
--- a/main.ts
+++ b/main.ts
@@ -20,14 +20,14 @@ async function main() {
 
   // Add event listener to sync viewState as query param.
   // Debounce to limit how quickly we are pushing to browser history
-  viewer.on(
-    "viewStateChange",
-    debounce((update: vizarr.ViewState) => {
-      const url = new URL(window.location.href);
-      url.searchParams.set("viewState", JSON.stringify(update));
-      window.history.pushState({}, "", decodeURIComponent(url.href));
-    }, 200),
-  );
+  // viewer.on(
+  //   "viewStateChange",
+  //   debounce((update: vizarr.ViewState) => {
+  //     const url = new URL(window.location.href);
+  //     url.searchParams.set("viewState", JSON.stringify(update));
+  //     window.history.pushState({}, "", decodeURIComponent(url.href));
+  //   }, 200),
+  // );
 
   // parse image config
   // @ts-expect-error - TODO: validate config
diff --git a/src/components/LayerController/Content.tsx b/src/components/LayerController/Content.tsx
index da2552c..787d6f0 100644
--- a/src/components/LayerController/Content.tsx
+++ b/src/components/LayerController/Content.tsx
@@ -6,6 +6,7 @@ import AcquisitionController from "./AcquisitionController";
 import AddChannelButton from "./AddChannelButton";
 import AxisSliders from "./AxisSliders";
 import ChannelController from "./ChannelController";
+import Labels from "./Labels";
 import OpacitySlider from "./OpacitySlider";
 
 import { useLayerState } from "../../hooks";
@@ -51,6 +52,19 @@ function Content() {
             <ChannelController channelIndex={i} key={i} />
           ))}
         </Grid>
+        {layer.labels?.length && (
+          <>
+            <Grid container justifyContent="space-between">
+              <Typography variant="caption">labels:</Typography>
+            </Grid>
+            <Divider />
+            <Grid>
+              {layer.labels.map((label, i) => (
+                <Labels labelIndex={i} key={label.layerProps.id} />
+              ))}
+            </Grid>
+          </>
+        )}
       </Grid>
     </Details>
   );
diff --git a/src/components/LayerController/Labels.tsx b/src/components/LayerController/Labels.tsx
new file mode 100644
index 0000000..81d6fe8
--- /dev/null
+++ b/src/components/LayerController/Labels.tsx
@@ -0,0 +1,73 @@
+import { Grid, IconButton, Slider, Typography } from "@material-ui/core";
+import { RadioButtonChecked, RadioButtonUnchecked } from "@material-ui/icons";
+import React from "react";
+
+import { useLayerState, useSourceData } from "../../hooks";
+import { assert } from "../../utils";
+
+export default function Labels({ labelIndex }: { labelIndex: number }) {
+  const [source] = useSourceData();
+  const [layer, setLayer] = useLayerState();
+  assert(source.labels && layer.kind === "multiscale" && layer.labels, "Missing image labels");
+
+  const handleOpacityChange = (_: unknown, value: number | number[]) => {
+    setLayer((prev) => {
+      assert(prev.kind === "multiscale" && prev.labels, "Missing image labels");
+      return {
+        ...prev,
+        labels: prev.labels.with(labelIndex, {
+          ...prev.labels[labelIndex],
+          layerProps: {
+            ...prev.labels[labelIndex].layerProps,
+            opacity: value as number,
+          },
+        }),
+      };
+    });
+  };
+
+  const { name } = source.labels[labelIndex];
+  const label = layer.labels[labelIndex];
+  return (
+    <>
+      <Grid container justifyContent="space-between" wrap="nowrap">
+        <div style={{ width: 165, overflow: "hidden", textOverflow: "ellipsis" }}>
+          <Typography variant="caption" noWrap>
+            {name}
+          </Typography>
+        </div>
+      </Grid>
+      <Grid container justifyContent="space-between">
+        <Grid item xs={2}>
+          <IconButton
+            style={{ backgroundColor: "transparent", padding: 0, zIndex: 2 }}
+            onClick={() => {
+              setLayer((prev) => {
+                assert(prev.kind === "multiscale" && prev.labels, "Missing image labels");
+                return {
+                  ...prev,
+                  labels: prev.labels.with(labelIndex, {
+                    ...prev.labels[labelIndex],
+                    on: !prev.labels[labelIndex].on,
+                  }),
+                };
+              });
+            }}
+          >
+            {label.on ? <RadioButtonChecked /> : <RadioButtonUnchecked />}
+          </IconButton>
+        </Grid>
+        <Grid item xs={10}>
+          <Slider
+            value={label.layerProps.opacity}
+            onChange={handleOpacityChange}
+            min={0}
+            max={1}
+            step={0.01}
+            style={{ padding: "10px 0px 5px 0px" }}
+          />
+        </Grid>
+      </Grid>
+    </>
+  );
+}
diff --git a/src/components/LayerController/index.tsx b/src/components/LayerController/index.tsx
index 287f1fd..8e13043 100644
--- a/src/components/LayerController/index.tsx
+++ b/src/components/LayerController/index.tsx
@@ -31,7 +31,6 @@ const Accordion = withStyles({
 function LayerController() {
   const [sourceInfo] = useSourceData();
   const layerAtom = layerFamilyAtom(sourceInfo);
-  const { name = "" } = sourceInfo;
   return (
     <LayerStateContext.Provider value={layerAtom}>
       <Accordion defaultExpanded>
diff --git a/src/io.ts b/src/io.ts
index d0dc52d..fd97096 100644
--- a/src/io.ts
+++ b/src/io.ts
@@ -3,6 +3,7 @@ import { ZarrPixelSource } from "./ZarrPixelSource";
 import { loadOmeMultiscales, loadPlate, loadWell } from "./ome";
 import * as utils from "./utils";
 
+import { DEFAULT_LABEL_OPACITY } from "./layers/label-layer";
 import type { BaseLayerProps } from "./layers/viv-layers";
 import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state";
 
@@ -218,6 +219,21 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L
     };
   }
 
+  let labels = undefined;
+  if (source.labels && source.labels.length > 0) {
+    labels = source.labels.map((label, i) => ({
+      on: false,
+      transformSourceSelection: getSourceSelectionTransform(label.loader[0], source.loader[0]),
+      layerProps: {
+        id: `${source.id}_${i}`,
+        loader: label.loader,
+        modelMatrix: label.modelMatrix,
+        opacity: DEFAULT_LABEL_OPACITY,
+        colors: label.colors,
+      },
+    }));
+  }
+
   return {
     kind: "multiscale",
     layerProps: {
@@ -225,5 +241,36 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L
       loader: source.loader,
     },
     on: true,
+    labels,
+  };
+}
+
+function getSourceSelectionTransform(
+  labels: { shape: Array<number>; labels: Array<string> },
+  source: { shape: Array<number>; labels: Array<string> },
+) {
+  utils.assert(
+    source.shape.length === source.labels.length,
+    `Image source axes and shape are not same rank. Got ${JSON.stringify(source)}`,
+  );
+  utils.assert(
+    labels.shape.length === labels.labels.length,
+    `Label axes and shape are not same rank. Got ${JSON.stringify(labels)}`,
+  );
+  utils.assert(
+    labels.labels.every((label) => source.labels.includes(label)),
+    `Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(labels.labels)}`,
+  );
+  // Identify labels that should always map to 0, regardless of the source selection.
+  const excludeFromTransformedSelection = new Set(
+    utils
+      .zip(labels.labels, labels.shape)
+      .filter(([_, size]) => size === 1)
+      .map(([name, _]) => name),
+  );
+  return (sourceSelection: Array<number>): Array<number> => {
+    return labels.labels.map((name) =>
+      excludeFromTransformedSelection.has(name) ? 0 : sourceSelection[source.labels.indexOf(name)],
+    );
   };
 }
diff --git a/src/layers/label-layer.ts b/src/layers/label-layer.ts
new file mode 100644
index 0000000..daccab8
--- /dev/null
+++ b/src/layers/label-layer.ts
@@ -0,0 +1,321 @@
+import { BitmapLayer, TileLayer } from "deck.gl";
+import * as utils from "../utils";
+
+import type { Layer, UpdateParameters } from "deck.gl";
+import { type Matrix4, clamp } from "math.gl";
+import type * as zarr from "zarrita";
+import type { ZarrPixelSource } from "../ZarrPixelSource";
+
+type Texture = ReturnType<BitmapLayer["context"]["device"]["createTexture"]>;
+
+export const DEFAULT_LABEL_OPACITY = 0.5;
+
+export type OmeColor = Readonly<{
+  labelValue: number;
+  rgba: readonly [r: number, g: number, b: number, a: number];
+}>;
+
+export interface LabelLayerProps {
+  id: string;
+  loader: Array<ZarrPixelSource>;
+  selection: Array<number>;
+  opacity: number;
+  modelMatrix: Matrix4;
+  colors?: ReadonlyArray<OmeColor>;
+}
+
+/**
+ * @see {@href https://ngff.openmicroscopy.org/0.5/index.html#labels-md}
+ *
+ * The pixels of the label images MUST be integer data types, i.e. one of [uint8, int8, uint16, int16, uint32, int32, uint64, int64].
+ */
+type LabelDataType = zarr.Uint8 | zarr.Int8 | zarr.Uint16 | zarr.Int16 | zarr.Uint32 | zarr.Int32;
+// TODO: bigint data types are supported by the spec but not by Viv's data loader.
+// | zarr.Uint64
+// | zarr.Int64;
+
+/** The decoded tile data from a OME-NGFF label source */
+type LabelPixelData = {
+  data: zarr.TypedArray<LabelDataType>;
+  width: number;
+  height: number;
+};
+
+export class LabelLayer extends TileLayer<LabelPixelData, LabelLayerProps> {
+  static layerName = "VizarrLabelLayer";
+  // @ts-expect-error - only way to extend the base state type
+  state!: { colorTexture: Texture } & TileLayer["state"];
+
+  constructor(props: LabelLayerProps) {
+    const resolutions = props.loader;
+    const dimensions = {
+      height: resolutions[0].shape.at(-2),
+      width: resolutions[0].shape.at(-1),
+    };
+    utils.assert(dimensions.width && dimensions.height);
+    const tileSize = getTileSizeForResolutions(resolutions);
+    super({
+      id: `labels-${props.id}`,
+      extent: [0, 0, dimensions.width, dimensions.height],
+      tileSize: tileSize,
+      minZoom: Math.round(-(resolutions.length - 1)),
+      opacity: props.opacity,
+      maxZoom: 0,
+      modelMatrix: props.modelMatrix,
+      colors: props.colors,
+      zoomOffset: Math.round(Math.log2(props.modelMatrix ? props.modelMatrix.getScale()[0] : 1)),
+      updateTriggers: {
+        getTileData: [props.loader, props.selection],
+      },
+      async getTileData({ index, signal }) {
+        const { x, y, z } = index;
+        const resolution = resolutions[Math.round(-z)];
+        const request = { x, y, signal, selection: props.selection };
+        let { data, width, height } = await resolution.getTile(request);
+        utils.assert(
+          !(data instanceof Float32Array) && !(data instanceof Float64Array),
+          `The pixels of labels MUST be integer data types, got ${JSON.stringify(resolution.dtype)}`,
+        );
+        return { data, width, height };
+      },
+    });
+  }
+
+  renderSubLayers(
+    params: TileLayer["props"] & {
+      data: LabelPixelData;
+      tile: {
+        index: { x: number; y: number; z: number };
+        boundingBox: [min: Array<number>, max: Array<number>];
+      };
+    },
+  ): Layer {
+    const { tile, data, ...props } = params;
+    const [[left, bottom], [right, top]] = tile.boundingBox;
+    utils.assert(props.extent, "missing extent");
+    const [_x0, _y0, width, height] = props.extent;
+    return new GrayscaleBitmapLayer({
+      id: `tile-${tile.index.x}.${tile.index.y}.${tile.index.z}-${props.id}`,
+      pixelData: data,
+      opacity: props.opacity,
+      modelMatrix: props.modelMatrix,
+      colorTexture: this.state.colorTexture,
+      bounds: [clamp(left, 0, width), clamp(top, 0, height), clamp(right, 0, width), clamp(bottom, 0, height)],
+      // For underlying class
+      image: new ImageData(data.width, data.height),
+      pickable: false,
+    });
+  }
+
+  updateState({ props, oldProps, changeFlags, ...rest }: UpdateParameters<this>): void {
+    super.updateState({ props, oldProps, changeFlags, ...rest });
+    // we make the colorTexture on this layer so we can share it amoung all the sublayers
+    if (props.colors !== oldProps.colors || !this.state.colorTexture) {
+      this.state.colorTexture?.destroy();
+      const colorTexture = createColorTexture({
+        source: props.colors,
+        maxTextureDimension2D: this.context.device.limits.maxTextureDimension2D,
+      });
+      this.setState({
+        colorTexture: this.context.device.createTexture({
+          width: colorTexture.width,
+          height: colorTexture.height,
+          data: colorTexture.data,
+          dimension: "2d",
+          mipmaps: false,
+          sampler: {
+            minFilter: "nearest",
+            magFilter: "nearest",
+            addressModeU: "clamp-to-edge",
+            addressModeV: "clamp-to-edge",
+          },
+          format: "rgba8unorm",
+        }),
+      });
+    }
+  }
+}
+
+export class GrayscaleBitmapLayer extends BitmapLayer<{ pixelData: LabelPixelData; colorTexture: Texture }> {
+  static layerName = "VizarrGrayscaleBitmapLayer";
+  // @ts-expect-error - only way to extend the base state type
+  state!: { texture: Texture } & BitmapLayer["state"];
+
+  getShaders() {
+    const sampler = (
+      {
+        Uint8Array: "usampler2D",
+        Uint16Array: "usampler2D",
+        Uint32Array: "usampler2D",
+        Int8Array: "isampler2D",
+        Int16Array: "isampler2D",
+        Int32Array: "isampler2D",
+      } as const
+    )[typedArrayConstructorName(this.props.pixelData.data)];
+    // replace the builtin fragment shader with our own
+    return {
+      ...super.getShaders(),
+      fs: `\
+#version 300 es
+#define SHADER_NAME grayscale-bitmap-layer-fragment-shader
+
+precision highp float;
+precision highp int;
+precision highp ${sampler};
+
+uniform ${sampler} grayscaleTexture;
+uniform sampler2D colorTexture;
+uniform float colorTextureWidth;
+uniform float colorTextureHeight;
+uniform float opacity;
+
+in vec2 vTexCoord;
+out vec4 fragColor;
+
+void main() {
+  int index = int(texture(grayscaleTexture, vTexCoord).r);
+  float x = (mod(float(index), colorTextureWidth) + 0.5) / colorTextureWidth;
+  float y = (floor(float(index) / colorTextureWidth) + 0.5) / colorTextureHeight;
+  vec2 uv = vec2(x, y);
+  vec3 color = texture(colorTexture, uv).rgb;
+  fragColor = vec4(color, ((index > 0) ? 1.0 : 0.0) * opacity);
+}
+`,
+    };
+  }
+
+  updateState({ props, oldProps, changeFlags, ...rest }: UpdateParameters<this>): void {
+    super.updateState({ props, oldProps, changeFlags, ...rest });
+    if (props.pixelData !== oldProps.pixelData) {
+      this.state.texture?.destroy();
+      this.setState({
+        texture: this.context.device.createTexture({
+          width: props.pixelData.width,
+          height: props.pixelData.height,
+          data: props.pixelData.data,
+          dimension: "2d",
+          mipmaps: false,
+          sampler: {
+            minFilter: "nearest",
+            magFilter: "nearest",
+            addressModeU: "clamp-to-edge",
+            addressModeV: "clamp-to-edge",
+          },
+          format: (
+            {
+              Uint8Array: "r8uint",
+              Uint16Array: "r16uint",
+              Uint32Array: "r32uint",
+              Int8Array: "r8sint",
+              Int16Array: "r16sint",
+              Int32Array: "r32sint",
+            } as const
+          )[typedArrayConstructorName(props.pixelData.data)],
+        }),
+      });
+    }
+  }
+
+  draw(opts: unknown) {
+    const { model, texture } = this.state;
+    const { colorTexture } = this.props;
+
+    if (model && texture && colorTexture) {
+      model.setUniforms({
+        colorTextureWidth: colorTexture.width,
+        colorTextureHeight: colorTexture.height,
+      });
+      model.setBindings({
+        grayscaleTexture: texture,
+        colorTexture: colorTexture,
+      });
+    }
+    super.draw(opts);
+  }
+}
+
+function getTileSizeForResolutions(resolutions: Array<ZarrPixelSource>): number {
+  const tileSize = resolutions[0].tileSize;
+  utils.assert(
+    resolutions.every((resolution) => resolution.tileSize === tileSize),
+    "resolutions must all have the same tile size",
+  );
+  return tileSize;
+}
+
+const SEEN_LUTS = new WeakSet<ReadonlyArray<OmeColor>>();
+
+/**
+ * Creates a color lookup table (LUT) as a 2D texture.
+ *
+ * @param options.source - The source lookup table.
+ * @param options.maxTextureDimension2D - The maximum texture dimension size.
+ */
+function createColorTexture(options: {
+  source?: ReadonlyArray<OmeColor>;
+  maxTextureDimension2D: number;
+}) {
+  const { source, maxTextureDimension2D } = options;
+  const fallback = {
+    data: DEFAULT_COLOR_TEXTURE,
+    width: DEFAULT_COLOR_TEXTURE.length / 4,
+    height: 1,
+  };
+
+  if (!source) {
+    return fallback;
+  }
+
+  // pack the colors into a 2D texture
+  const size = Math.max(...source.map((e) => e.labelValue)) + 1;
+  const width = Math.min(size, maxTextureDimension2D);
+  const height = Math.ceil(size / width);
+
+  if (width > maxTextureDimension2D || height > maxTextureDimension2D) {
+    if (!SEEN_LUTS.has(source)) {
+      console.warn("[vizarr] Skipping color palette from OME-NGFF `image-label` source: max texture dimension limit.");
+      SEEN_LUTS.add(source);
+    }
+    return fallback;
+  }
+
+  const data = new Uint8Array(width * height * 4);
+  for (const { labelValue, rgba } of source) {
+    const x = labelValue % width;
+    const y = Math.floor(labelValue / width);
+    const texIndex = (y * width + x) * 4;
+    data[texIndex] = rgba[0];
+    data[texIndex + 1] = rgba[1];
+    data[texIndex + 2] = rgba[2];
+    data[texIndex + 3] = rgba[3];
+  }
+
+  return { data, width, height };
+}
+
+// From Vitessce https://github.com/vitessce/vitessce/blob/03c6d5d843640982e984a0e309f1ba1807085128/packages/utils/other-utils/src/components.ts#L50-L67
+const DEFAULT_COLOR_TEXTURE = Uint8Array.from(
+  [
+    [0, 73, 73],
+    [0, 146, 146],
+    [255, 109, 182],
+    [255, 182, 219],
+    [73, 0, 146],
+    [0, 109, 219],
+    [182, 109, 255],
+    [109, 182, 255],
+    [182, 219, 255],
+    [146, 0, 0],
+    [146, 72, 0],
+    [219, 109, 0],
+    [36, 255, 36],
+    [255, 255, 109],
+    [255, 255, 255],
+  ].flatMap((color) => [...color, 255]),
+);
+
+function typedArrayConstructorName(arr: zarr.TypedArray<LabelDataType>) {
+  const ArrayType = arr.constructor as zarr.TypedArrayConstructor<LabelDataType>;
+  const name = ArrayType.name as `${Capitalize<LabelDataType>}Array`;
+  return name;
+}
diff --git a/src/ome.ts b/src/ome.ts
index 4d5396b..026e285 100644
--- a/src/ome.ts
+++ b/src/ome.ts
@@ -1,8 +1,9 @@
 import pMap from "p-map";
 import * as zarr from "zarrita";
-import type { ImageLayerConfig, OnClickData, SourceData } from "./state";
+import type { ImageLabels, ImageLayerConfig, OnClickData, SourceData } from "./state";
 
 import { ZarrPixelSource } from "./ZarrPixelSource";
+import type { OmeColor } from "./layers/label-layer";
 import * as utils from "./utils";
 
 export async function loadWell(
@@ -253,8 +254,8 @@ export async function loadOmeMultiscales(
   const axis_labels = utils.getNgffAxisLabels(axes);
   const meta = parseOmeroMeta(attrs.omero, axes);
   const tileSize = utils.guessTileSize(data[0]);
-
   const loader = data.map((arr) => new ZarrPixelSource(arr, { labels: axis_labels, tileSize }));
+  const labels = await resolveOmeLabelsFromMultiscales(grp);
   return {
     loader: loader,
     axis_labels,
@@ -268,9 +269,39 @@ export async function loadOmeMultiscales(
     },
     ...meta,
     name: meta.name ?? name,
+    labels: await Promise.all(labels.map((name) => loadOmeImageLabel(grp.resolve("labels"), name))),
   };
 }
 
+async function loadOmeImageLabel(root: zarr.Location<zarr.Readable>, name: string): Promise<ImageLabels[number]> {
+  const grp = await zarr.open(root.resolve(name), { kind: "group" });
+  const attrs = utils.resolveAttrs(grp.attrs);
+  utils.assert(utils.isOmeImageLabel(attrs), "No 'image-label' metadata.");
+  const data = await utils.loadMultiscales(grp, attrs.multiscales);
+  const baseResolution = data.at(0);
+  utils.assert(baseResolution, "No base resolution found for multiscale labels.");
+  const tileSize = utils.guessTileSize(baseResolution);
+  const axes = utils.getNgffAxes(attrs.multiscales);
+  const labels = utils.getNgffAxisLabels(axes);
+  const colors = (attrs["image-label"].colors ?? []).map((d) => ({ labelValue: d["label-value"], rgba: d.rgba }));
+  return {
+    name,
+    modelMatrix: utils.coordinateTransformationsToMatrix(attrs.multiscales),
+    loader: data.map((arr) => new ZarrPixelSource(arr, { labels, tileSize })),
+    colors: colors.length > 0 ? colors : undefined,
+  };
+}
+
+async function resolveOmeLabelsFromMultiscales(grp: zarr.Group<zarr.Readable>): Promise<Array<string>> {
+  return zarr
+    .open(grp.resolve("labels"), { kind: "group" })
+    .then(({ attrs }) => (attrs.labels ?? []) as Array<string>)
+    .catch((e) => {
+      utils.rethrowUnless(e, zarr.NodeNotFoundError);
+      return [];
+    });
+}
+
 type Meta = {
   name: string | undefined;
   names: Array<string>;
diff --git a/src/state.ts b/src/state.ts
index 6430d41..69c87e7 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -1,4 +1,4 @@
-import { atom } from "jotai";
+import { type Atom, atom } from "jotai";
 import { atomFamily, splitAtom, waitForAll } from "jotai/utils";
 import { RedirectError, rethrowUnless } from "./utils";
 
@@ -11,6 +11,7 @@ import type { ZarrPixelSource } from "./ZarrPixelSource";
 import { initLayerStateFromSource } from "./io";
 
 import { GridLayer, type GridLayerProps, type GridLoader } from "./layers/grid-layer";
+import { LabelLayer, type LabelLayerProps, type OmeColor } from "./layers/label-layer";
 import {
   ImageLayer,
   type ImageLayerProps,
@@ -54,6 +55,13 @@ export type OnClickData = Record<string, unknown> & {
   gridCoord?: { row: number; column: number };
 };
 
+export type ImageLabels = Array<{
+  name: string;
+  loader: ZarrPixelSource[];
+  modelMatrix: Matrix4;
+  colors?: ReadonlyArray<OmeColor>;
+}>;
+
 export type SourceData = {
   loader: ZarrPixelSource[];
   loaders?: GridLoader[]; // for OME plates
@@ -75,23 +83,9 @@ export type SourceData = {
   model_matrix: Matrix4;
   axis_labels: string[];
   onClick?: (e: OnClickData) => void;
+  labels?: ImageLabels;
 };
 
-export type VivProps = ConstructorParameters<typeof MultiscaleImageLayer>[0];
-
-export interface BaseLayerProps {
-  id: string;
-  contrastLimits: VivProps["contrastLimits"];
-  colors: [r: number, g: number, b: number][];
-  channelsVisible: NonNullable<VivProps["channelsVisible"]>;
-  opacity: NonNullable<VivProps["opacity"]>;
-  colormap: string; // TODO: more precise
-  selections: number[][];
-  modelMatrix: Matrix4;
-  contrastLimitsRange: [min: number, max: number][];
-  onClick?: (e: OnClickData) => void;
-}
-
 type LayerType = "image" | "multiscale" | "grid";
 type LayerPropsMap = {
   image: ImageLayerProps;
@@ -103,6 +97,11 @@ export type LayerState<T extends LayerType = LayerType> = {
   kind: T;
   layerProps: LayerPropsMap[T];
   on: boolean;
+  labels?: Array<{
+    layerProps: Omit<LabelLayerProps, "selection">;
+    on: boolean;
+    transformSourceSelection: (sourceSelection: Array<number>) => Array<number>;
+  }>;
 };
 
 type WithId<T> = T & { id: string };
@@ -146,7 +145,11 @@ export const layerFamilyAtom: AtomFamily<WithId<SourceData>, PrimitiveAtom<WithI
   (a, b) => a.id === b.id,
 );
 
-export type VizarrLayer = Layer<MultiscaleImageLayerProps> | Layer<ImageLayerProps> | Layer<GridLayerProps>;
+export type VizarrLayer =
+  | Layer<MultiscaleImageLayerProps>
+  | Layer<ImageLayerProps>
+  | Layer<GridLayerProps>
+  | Layer<LabelLayerProps>;
 
 const LayerConstructors = {
   image: ImageLayer,
@@ -154,15 +157,41 @@ const LayerConstructors = {
   grid: GridLayer,
 } as const;
 
+const layerInstanceFamily = atomFamily((a: Atom<LayerState>) =>
+  atom((get) => {
+    const { on, layerProps, kind } = get(a);
+    if (!on) {
+      return null;
+    }
+    const Layer = LayerConstructors[kind];
+    // @ts-expect-error - TS can't resolve that Layer & layerProps bound together
+    return new Layer(layerProps) as VizarrLayer;
+  }),
+);
+
+const imageLabelsIstanceFamily = atomFamily((a: Atom<LayerState>) =>
+  atom((get) => {
+    const { on, labels, layerProps } = get(a);
+    if (!on || !labels) {
+      return [];
+    }
+    return labels.map((label) =>
+      label.on
+        ? new LabelLayer({
+            ...label.layerProps,
+            selection: label.transformSourceSelection(layerProps.selections[0]),
+          })
+        : null,
+    );
+  }),
+);
+
 export const layerAtoms = atom((get) => {
-  const atoms = get(sourceInfoAtomAtoms);
-  if (atoms.length === 0) {
-    return [];
+  const layerAtoms = [];
+  for (const sourceAtom of get(sourceInfoAtomAtoms)) {
+    const layerStateAtom = layerFamilyAtom(get(sourceAtom));
+    layerAtoms.push(layerInstanceFamily(layerStateAtom));
+    layerAtoms.push(imageLabelsIstanceFamily(layerStateAtom));
   }
-  const layersState = get(waitForAll(atoms.map((a) => layerFamilyAtom(get(a)))));
-  return layersState.map((layer) => {
-    const Layer = LayerConstructors[layer.kind];
-    // @ts-expect-error - TS can't resolve that Layer & layerProps bound together
-    return layer.on ? new Layer(layer.layerProps) : null;
-  }) as Array<VizarrLayer | null>;
+  return get(waitForAll(layerAtoms)).flat();
 });
diff --git a/src/types.ts b/src/types.ts
index 9c800b2..59e959a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -85,9 +85,27 @@ declare namespace Ome {
     version: Version;
   }
 
+  interface ImageLabel {
+    version: Version;
+    colors?: Array<{
+      "label-value": number;
+      rgba: [r: number, g: number, b: number, a: number];
+    }>;
+    properties?: Array<{
+      "label-value": number;
+      "omero:roiId": number;
+      "omero:shapeId": number;
+    }>;
+    /** Location of source image */
+    source: {
+      image: string;
+    };
+  }
+
   type Attrs =
     | { multiscales: Multiscale[] }
     | { omero: Omero; multiscales: Multiscale[] }
     | { plate: Plate }
-    | { well: Well };
+    | { well: Well }
+    | { "image-label": ImageLabel; multiscales: Multiscale[] };
 }
diff --git a/src/utils.ts b/src/utils.ts
index ed64afe..fb51719 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -3,6 +3,7 @@ import * as zarr from "zarrita";
 
 import type { ZarrPixelSource } from "./ZarrPixelSource";
 import type { GridLayerProps } from "./layers/grid-layer";
+import type { LabelLayerProps } from "./layers/label-layer";
 import type { ImageLayerProps, MultiscaleImageLayerProps } from "./layers/viv-layers";
 import { lru } from "./lru-store";
 import type { ViewState } from "./state";
@@ -456,6 +457,12 @@ export function isOmeWell(attrs: zarr.Attributes): attrs is { well: Ome.Well } {
   return "well" in attrs;
 }
 
+export function isOmeImageLabel(
+  attrs: zarr.Attributes,
+): attrs is { "image-label": Ome.ImageLabel; multiscales: Ome.Multiscale[] } {
+  return "image-label" in attrs && isMultiscales(attrs);
+}
+
 export function isOmeMultiscales(attrs: zarr.Attributes): attrs is { omero: Ome.Omero; multiscales: Ome.Multiscale[] } {
   return "omero" in attrs && isMultiscales(attrs);
 }
@@ -508,12 +515,14 @@ export function rethrowUnless<E extends ReadonlyArray<new (...args: any[]) => Er
 }
 
 export function isGridLayerProps(
-  props: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps,
+  props: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps | LabelLayerProps,
 ): props is GridLayerProps {
   return "loaders" in props && "rows" in props && "columns" in props;
 }
 
-export function resolveLoaderFromLayerProps(layerProps: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps) {
+export function resolveLoaderFromLayerProps(
+  layerProps: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps | LabelLayerProps,
+) {
   return isGridLayerProps(layerProps) ? layerProps.loaders[0].loader : layerProps.loader;
 }
 
@@ -568,3 +577,17 @@ export function coordinateTransformationsToMatrix(multiscales: Array<Ome.Multisc
 
   return mat;
 }
+
+/**
+ * Builds N-tuples of elements from the given N arrays with matching indices,
+ * stopping when the smallest array's end is reached.
+ */
+export function zip<T extends unknown[]>(...arrays: { [K in keyof T]: ReadonlyArray<T[K]> }): T[] {
+  const minLength = arrays.reduce((minLength, arr) => Math.min(arr.length, minLength), Number.POSITIVE_INFINITY);
+  const result: T[] = new Array(minLength);
+  for (let i = 0; i < minLength; i += 1) {
+    const arr = arrays.map((it) => it[i]);
+    result[i] = arr as T;
+  }
+  return result;
+}
diff --git a/vite.config.js b/vite.config.js
index f2deb49..409e82e 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -3,8 +3,6 @@ import * as path from "node:path";
 import react from "@vitejs/plugin-react";
 import { defineConfig } from "vite";
 
-const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr";
-
 /**
  * Writes a new entry point that exports contents of an existing chunk.
  * @param {string} entryPointName - Name of the new entry point
@@ -44,6 +42,6 @@ export default defineConfig({
     },
   },
   server: {
-    open: `?source=${source}`,
+    open: `?source=${process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"}`,
   },
 });