diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..af3b9f02
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+---
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "monthly"
+    groups:
+      github-actions:
+        patterns:
+          - "*"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 30a2c3eb..6a5d182f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,22 +1,33 @@
 name: Build docs
+permissions:
+  contents: read
 
 on:
+
   pull_request:
     branches:
       - main
+  push:
+    branches:
+      - main
+    tags:
+      - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
+  workflow_dispatch:
 
 jobs:
   build-docs:
     name: Build & Upload Artifact
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
 
-      - uses: actions/setup-python@v3
+      - uses: actions/setup-python@v5
         with:
           python-version: "3.10"
 
-      - uses: tlambert03/setup-qt-libs@v1
+      - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1
 
       - name: Install Dependencies
         run: |
@@ -25,13 +36,13 @@ jobs:
           sudo apt install graphviz --yes
 
       - name: Build Docs
-        uses: aganders3/headless-gui@v1
+        uses: aganders3/headless-gui@f85dd6316993505dfc5f21839d520ae440c84816 # v2
         with:
           run: make html
           working-directory: ./docs
 
       - name: Upload artifact
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: docs
           path: docs/_build
@@ -40,16 +51,18 @@ jobs:
     name: Upload docs to GitHub pages
     runs-on: ubuntu-latest
     needs: build-docs
-    if: github.event_name == 'push' && github.ref_name == 'main'
+    if: contains(github.ref, 'tags')
     steps:
-    - uses: actions/checkout@v3
-    - uses: actions/download-artifact@v3
+    - uses: actions/checkout@v4
+      with:
+        persist-credentials: false
+    - uses: actions/download-artifact@v4.3.0
       with:
         name: docs
 
     - name: Push to GitHub pages
-      uses: JamesIves/github-pages-deploy-action@v4
+      uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4
       with:
         folder: html
         ssh-key: ${{ secrets.DEPLOY_KEY }}
-        repository-name: dstansby/napari-matplotlib-docs
+        repository-name: napari-matplotlib/napari-matplotlib.github.io
diff --git a/.github/workflows/napari_hub_preview.yml b/.github/workflows/napari_hub_preview.yml
deleted file mode 100644
index c204ac45..00000000
--- a/.github/workflows/napari_hub_preview.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: napari hub Preview Page # we use this name to find your preview page artifact, so don't change it!
-# For more info on this action, see https://github.com/chanzuckerberg/napari-hub-preview-action/blob/main/action.yml
-
-on:
-  pull_request:
-    types: [ labeled ]
-
-jobs:
-  preview-page:
-    if: ${{ github.event.label.name == 'napari hub preview' }}
-    name: Preview Page Deploy
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout repo
-        uses: actions/checkout@v3
-
-      - name: napari hub Preview Page Builder
-        uses: chanzuckerberg/napari-hub-preview-action@v0.1
-        with:
-          hub-ref: main
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index f488fa13..0521c9e6 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -1,4 +1,6 @@
 name: tests
+permissions:
+  contents: read
 
 on:
   push:
@@ -12,7 +14,24 @@ on:
   workflow_dispatch:
   merge_group:
 
+concurrency:
+    group: ${{ github.workflow }}-${{ github.ref }}
+    cancel-in-progress: true
+
 jobs:
+  pre-commit:
+    name: precommit
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55  # v5.5.0
+        with:
+          python-version: "3.x"
+      - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd  # v3.0.1
+        with:
+          extra_args: --hook-stage manual --all-files
   test:
     name: ${{ matrix.platform }} py${{ matrix.python-version }}
     runs-on: ${{ matrix.platform }}
@@ -20,18 +39,20 @@ jobs:
       fail-fast: false
       matrix:
         platform: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ['3.8', '3.9', '3.10']
+        python-version: ['3.10', '3.11', '3.12']
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v3
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
       # these libraries enable testing on Qt on linux
-      - uses: tlambert03/setup-qt-libs@v1
+      - uses: tlambert03/setup-qt-libs@19e4ef2d781d81f5f067182e228b54ec90d23b76 # v1
 
       # strategy borrowed from vispy for installing opengl libs on windows
       - name: Install Windows OpenGL
@@ -50,7 +71,7 @@ jobs:
         run: python -m tox
 
       - name: Upload pytest test results
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: pytest-results-${{ matrix.platform }} py${{ matrix.python-version }}
           path: reports/
@@ -58,13 +79,15 @@ jobs:
         if: ${{ always() }}
 
       - name: Coverage
-        uses: codecov/codecov-action@v3
+        uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
         # Don't run coverage on merge queue CI to avoid duplicating reports
         # to codecov. See https://github.com/matplotlib/napari-matplotlib/issues/155
         if: github.event_name != 'merge_group'
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
-          fail_ci_if_error: true
+          fail_ci_if_error: false
+
+
 
   deploy:
     # this will run when you have tagged a commit, starting with "v*"
@@ -73,21 +96,26 @@ jobs:
     needs: [test]
     runs-on: ubuntu-latest
     if: contains(github.ref, 'tags')
+    environment: pypi-deploy
+    permissions:
+      id-token: write
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: "3.x"
-      - name: Install dependencies
+      - name: Install build
         run: |
           python -m pip install --upgrade pip
-          pip install -U setuptools setuptools_scm wheel twine build
-      - name: Build and publish
-        env:
-          TWINE_USERNAME: __token__
-          TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
+          python -m pip install --upgrade build
+
+      - name: Build package
         run: |
           git tag
           python -m build .
-          twine upload dist/*
+
+      - name: Publish package
+        uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1a3e77f3..e592dea1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,18 +1,13 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v5.0.0
     hooks:
       - id: check-docstring-first
       - id: end-of-file-fixer
       - id: trailing-whitespace
 
-  - repo: https://github.com/asottile/setup-cfg-fmt
-    rev: v2.3.0
-    hooks:
-      - id: setup-cfg-fmt
-
-  - repo: https://github.com/psf/black
-    rev: 23.3.0
+  - repo: https://github.com/psf/black-pre-commit-mirror
+    rev: 25.1.0
     hooks:
       - id: black
 
@@ -22,14 +17,14 @@ repos:
       - id: napari-plugin-checks
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.3.0
+    rev: v1.15.0
     hooks:
      - id: mypy
        additional_dependencies: [numpy, matplotlib]
 
-  - repo: https://github.com/charliermarsh/ruff-pre-commit
+  - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: 'v0.0.272'
+    rev: 'v0.11.9'
     hooks:
       - id: ruff
 
diff --git a/MANIFEST.in b/MANIFEST.in
index d625d95e..7ce16f9b 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,5 @@
 include LICENSE
 include README.md
-recursive-include * *.mplstyle
 
 recursive-exclude * __pycache__
 recursive-exclude * *.py[co]
diff --git a/README.md b/README.md
index 087b2864..fb7aa635 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@
 [![Python Version](https://img.shields.io/pypi/pyversions/napari-matplotlib.svg?color=green)](https://python.org)
 [![tests](https://github.com/matplotlib/napari-matplotlib/workflows/tests/badge.svg)](https://github.com/matplotlib/napari-matplotlib/actions)
 [![codecov](https://codecov.io/gh/matplotlib/napari-matplotlib/branch/main/graph/badge.svg)](https://codecov.io/gh/matplotlib/napari-matplotlib)
-[![readthedocs](https://readthedocs.org/projects/napari-matplotlib/badge/?version=latest)](https://napari-matplotlib.readthedocs.io/en/latest/)
 [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/matplotlib/pytest-mpl/master.svg)](https://results.pre-commit.ci/latest/github/matplotlib/pytest-mpl/master)
 [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-matplotlib)](https://napari-hub.org/plugins/napari-matplotlib)
 
@@ -16,32 +15,7 @@ A plugin to create Matplotlib plots from napari layers
 ## Introduction
 `napari-matplotlib` is a bridge between `napari` and `matplotlib`, making it easy to create publication quality `Matplotlib` plots based on the data loaded in `napari` layers.
 
-## Available widgets
-
-### `Slice`
-Plots 1D slices of data along a specified axis.
-![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/slice.png)
-
-### `Histogram`
-Plots histograms of individual image layers, or RGB histograms of an RGB image
-![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/hist.png)
-
-### `Scatter`
-Scatters the values of two similarly sized images layers against each other.
-![](https://raw.githubusercontent.com/matplotlib/napari-matplotlib/main/examples/scatter.png)
-
-## Installation
-
-You can install `napari-matplotlib` via [pip]:
-
-    pip install napari-matplotlib
-
-
-
-To install latest development version :
-
-    pip install git+https://github.com/matplotlib/napari-matplotlib.git
-
+Documentation can be found at https://napari-matplotlib.github.io/
 
 ## Contributing
 
diff --git a/docs/_static/hist.png b/docs/_static/hist.png
new file mode 100644
index 00000000..95850b71
Binary files /dev/null and b/docs/_static/hist.png differ
diff --git a/docs/_static/logo.png b/docs/_static/logo.png
new file mode 100644
index 00000000..92232b6c
Binary files /dev/null and b/docs/_static/logo.png differ
diff --git a/docs/changelog.rst b/docs/changelog.rst
index db3bc343..60dd72ba 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,7 +1,107 @@
 Changelog
 =========
 
-0.3.0
+2.1.0
+-----
+New features
+~~~~~~~~~~~~
+- Added a GUI element to manually set the number of bins in the histogram widgets.
+
+2.0.3
+-----
+Bug fixes
+~~~~~~~~~
+- Fix an error that happened when the histogram widget was open, but a layer that doesn't support
+  histogramming (e.g., a labels layer) was selected.
+
+2.0.2
+-----
+Dependencies
+~~~~~~~~~~~~
+napari-matplotlib now adheres to `SPEC 0 <https://scientific-python.org/specs/spec-0000/>`_, and has:
+
+- Dropped support for Python 3.9
+- Added support for Python 3.12
+- Added a minimum required numpy verison of 1.23
+- Pinned the maximum napari version to ``< 0.5``.
+  Version 3.0 of ``napari-matplotlib`` will introduce support for ``napari`` version 0.5.
+
+2.0.1
+-----
+Bug fixes
+~~~~~~~~~
+- Fixed using the ``HistogramWidget`` with layers containing multiscale data.
+- Make sure ``HistogramWidget`` uses 100 bins (not 99) when floating point data is
+  selected.
+
+2.0.0
+-----
+Changes to custom theming
+~~~~~~~~~~~~~~~~~~~~~~~~~
+``napari-matplotlib`` now uses colours from the current napari theme to customise the
+Matplotlib plots. See `the example on creating a new napari theme
+<https://napari.org/stable/gallery/new_theme.html>`_ for a helpful guide on how to
+create custom napari themes.
+
+This means support for custom Matplotlib styles sheets has been removed.
+
+If you spot any issues with the new theming, please report them at
+https://github.com/matplotlib/napari-matplotlib/issues.
+
+Other changes
+~~~~~~~~~~~~~
+- Histogram bin sizes for integer-type data are now force to be an integer.
+- The ``HistogramWidget`` now has two vertical lines showing the contrast limits used
+  to render the selected layer in the main napari window.
+- Added an example gallery for the ``FeaturesHistogramWidget``.
+
+1.2.0
+-----
+Changes
+~~~~~~~
+- Dropped support for Python 3.8, and added support for Python 3.11.
+- Histogram plots of points and vector layers are now coloured with their napari colourmap.
+- Added support for Matplotlib 3.8
+
+1.1.0
+-----
+Additions
+~~~~~~~~~
+- Added a widget to draw a histogram of features.
+
+Changes
+~~~~~~~
+- The slice widget is now limited to slicing along the x/y dimensions. Support
+  for slicing along z has been removed for now to make the code simpler.
+- The slice widget now uses a slider to select the slice value.
+
+Bug fixes
+~~~~~~~~~
+- Fixed creating 1D slices of 2D images.
+- Removed the limitation that only the first 99 indices could be sliced using
+  the slice widget.
+
+1.0.2
+-----
+Bug fixes
+~~~~~~~~~
+- A full dataset is no longer read into memory when using ``HistogramWidget``.
+  Only the current slice is loaded.
+- Fixed compatibility with napari 0.4.18.
+
+Changes
+~~~~~~~
+- Histogram bin limits are now caclualted from the slice being histogrammed, and
+  not the whole dataset. This is as a result of the above bug fix.
+
+1.0.1
+-----
+Bug fixes
+~~~~~~~~~
+- Pinned that maximum version of `napari` to 0.4.17, since ``napari-matplotlib``
+  does not yet work with ``napari`` 0.4.18.
+
+1.0.0
 -----
 
 New features
@@ -15,6 +115,8 @@ Visual improvements
 ~~~~~~~~~~~~~~~~~~~
 - The background of ``napari-matplotlib`` figures and axes is now transparent, and the text and axis colour respects the ``napari`` theme.
 - The icons in the Matplotlib toolbar are now the same size as icons in the napari window.
+- Custom style sheets can now be set to customise plots. See the user guide
+  for more information.
 
 Changes
 ~~~~~~~
diff --git a/docs/conf.py b/docs/conf.py
index 4351e610..f1533830 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,7 +13,7 @@
 # import os
 # import sys
 # sys.path.insert(0, os.path.abspath('.'))
-import qtgallery
+from sphinx_gallery import scrapers
 
 # -- Project information -----------------------------------------------------
 
@@ -35,18 +35,58 @@
     "sphinx.ext.intersphinx",
 ]
 
+
+def reset_napari(gallery_conf, fname):  # type: ignore[no-untyped-def]
+    from napari.settings import get_settings
+    from qtpy.QtWidgets import QApplication
+
+    settings = get_settings()
+    settings.appearance.theme = "dark"
+
+    # Disabling `QApplication.exec_` means example scripts can call `exec_`
+    # (scripts work when run normally) without blocking example execution by
+    # sphinx-gallery. (from qtgallery)
+    QApplication.exec_ = lambda _: None
+
+
+def napari_scraper(block, block_vars, gallery_conf):  # type: ignore[no-untyped-def]
+    """Basic napari window scraper.
+
+    Looks for any QtMainWindow instances and takes a screenshot of them.
+
+    `app.processEvents()` allows Qt events to propagateo and prevents hanging.
+    """
+    import napari
+
+    imgpath_iter = block_vars["image_path_iterator"]
+
+    if app := napari.qt.get_app():
+        app.processEvents()
+    else:
+        return ""
+
+    img_paths = []
+    for win, img_path in zip(
+        reversed(napari._qt.qt_main_window._QtMainWindow._instances),
+        imgpath_iter,
+        strict=False,
+    ):
+        img_paths.append(img_path)
+        win._window.screenshot(img_path, canvas_only=False)
+
+    napari.Viewer.close_all()
+    app.processEvents()
+
+    return scrapers.figure_rst(img_paths, gallery_conf["src_dir"])
+
+
 sphinx_gallery_conf = {
     "filename_pattern": ".",
-    "image_scrapers": (qtgallery.qtscraper,),
-    "reset_modules": (qtgallery.reset_qapp,),
+    "image_scrapers": (napari_scraper,),
+    "reset_modules": (reset_napari,),
 }
+suppress_warnings = ["config.cache"]
 
-qtgallery_conf = {
-    "xvfb_size": (640, 480),
-    "xvfb_color_depth": 24,
-    "xfvb_use_xauth": False,
-    "xfvb_extra_args": [],
-}
 
 numpydoc_show_class_members = False
 automodapi_inheritance_diagram = True
@@ -87,6 +127,7 @@
 # a list of builtin themes.
 #
 html_theme = "pydata_sphinx_theme"
+html_logo = "_static/logo.png"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
diff --git a/docs/guide/index.rst b/docs/guide/index.rst
deleted file mode 100644
index 968ea0ec..00000000
--- a/docs/guide/index.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-Guide
-=====
-
-.. toctree::
-   :maxdepth: 1
-
-   user_guide
-   third_party
diff --git a/docs/index.rst b/docs/index.rst
index 5b747e04..0eb92b04 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,23 +1,22 @@
 napari-matplotlib
 =================
-``napari-matplotlib`` is a `Napari <https://napari.org>`_ plugin for generating
+A `napari <https://napari.org>`_ plugin for generating
 `Matplotlib <https://matplotlib.org/>`_ plots from one or more ``napari`` Layers.
 
-Design
-------
-``napari-matplotlib`` contains a number of different ``napari`` Widgets. Each
-widget is designed to map one or more ``napari`` Layers on to a ``matplotlib`` plot.
+This plugin provides a number of different ``napari`` Widgets. Each
+widget is designed to map one or more ``napari`` Layers on to a ``Matplotlib`` plot.
 As an example, the `~.HistogramWidget` is used to
 map one or more Image layers on to a 1D histogram plot.
 
-.. toctree::
-   :maxdepth: 1
-
-   guide/index
+.. image:: _static/hist.png
+  :alt: Screenshot of the napari viewer showing the napari-matplotlib histogram widget on the right hand side of the screen.
 
 .. toctree::
    :maxdepth: 1
-   :caption: Reference
+   :hidden:
 
+   user_guide
+   auto_examples/index
+   third_party
    api
    changelog
diff --git a/docs/guide/third_party.rst b/docs/third_party.rst
similarity index 94%
rename from docs/guide/third_party.rst
rename to docs/third_party.rst
index d892ba8d..c09eb532 100644
--- a/docs/guide/third_party.rst
+++ b/docs/third_party.rst
@@ -1,8 +1,6 @@
 Third-party plugins
 ===================
-This page explains how ``napari-matplotlib`` can be used within third party plugins.
-
-``napari-matplotlib`` provides a ready-to-go widget with a Matplotlib toolbar and figure to third party plugin developers
+``napari-matplotlib`` provides a ready-to-go widget with a Matplotlib toolbar and figure to third party plugin developers.
 This widget is customised to match the theme of the main napari window.
 
 The widget can be found at `napari_matplotlib.base.NapariMPLWidget`.
diff --git a/docs/guide/user_guide.rst b/docs/user_guide.rst
similarity index 81%
rename from docs/guide/user_guide.rst
rename to docs/user_guide.rst
index 15b44f44..253e3149 100644
--- a/docs/guide/user_guide.rst
+++ b/docs/user_guide.rst
@@ -30,9 +30,16 @@ These widgets plot the data stored in the ``.features`` attribute of individual
 Currently available are:
 
 - 2D scatter plots of two features against each other.
+- Histograms of individual features.
 
 To use these:
 
 1. Open the desired widget using the ``Plugins > napari-matplotlib`` menu in napari.
 2. Select a single layer that has a features table using the napari layers list in the bottom left-hand side of the window.
 3. Use the drop down menu(s) under the Matplotlib figure to select the feature(s) to plot.
+
+Customising plots
+-----------------
+``napari-matplotlib`` uses colours from the current napari theme to customise the
+Matplotlib plots. See `the example on creating a new napari theme
+<https://napari.org/stable/gallery/new_theme.html>`_ for a helpful guide.
diff --git a/examples/README.rst b/examples/README.rst
index bac945d5..ae12bff3 100644
--- a/examples/README.rst
+++ b/examples/README.rst
@@ -1,2 +1,2 @@
-Examples
-========
+Gallery
+=======
diff --git a/examples/features_hist.py b/examples/features_hist.py
new file mode 100644
index 00000000..899ddef3
--- /dev/null
+++ b/examples/features_hist.py
@@ -0,0 +1,42 @@
+"""
+Hisogram of features
+====================
+"""
+
+import napari
+import numpy as np
+import numpy.typing as npt
+from skimage.measure import regionprops_table
+
+# make a test label image
+label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
+
+label_image[10:20, 10:20] = 1
+label_image[50:70, 50:70] = 2
+
+feature_table_1 = regionprops_table(
+    label_image, properties=("label", "area", "perimeter")
+)
+feature_table_1["index"] = feature_table_1["label"]
+
+# make the points data
+n_points = 100
+points_data = 100 * np.random.random((100, 2))
+points_features = {
+    "feature_0": np.random.random((n_points,)),
+    "feature_1": np.random.random((n_points,)),
+    "feature_2": np.random.random((n_points,)),
+}
+
+# create the viewer
+viewer = napari.Viewer()
+viewer.add_labels(label_image, features=feature_table_1)
+viewer.add_points(points_data, features=points_features)
+
+# make the widget
+viewer.window.add_plugin_dock_widget(
+    plugin_name="napari-matplotlib", widget_name="FeaturesHistogram"
+)
+
+if __name__ == "__main__":
+    napari.run()
diff --git a/examples/features_scatter.py b/examples/features_scatter.py
index 5b4d407a..f4ea7f66 100644
--- a/examples/features_scatter.py
+++ b/examples/features_scatter.py
@@ -5,10 +5,11 @@
 
 import napari
 import numpy as np
+import numpy.typing as npt
 from skimage.measure import regionprops_table
 
 # make a test label image
-label_image = np.zeros((100, 100), dtype=np.uint16)
+label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
 
 label_image[10:20, 10:20] = 1
 label_image[50:70, 50:70] = 2
diff --git a/examples/histogram.py b/examples/histogram.py
index ccda491a..b9ceb377 100644
--- a/examples/histogram.py
+++ b/examples/histogram.py
@@ -2,6 +2,7 @@
 Histograms
 ==========
 """
+
 import napari
 
 viewer = napari.Viewer()
diff --git a/examples/scatter.py b/examples/scatter.py
index cd812401..00e01ec9 100644
--- a/examples/scatter.py
+++ b/examples/scatter.py
@@ -2,6 +2,7 @@
 Scatter plots
 =============
 """
+
 import napari
 
 viewer = napari.Viewer()
diff --git a/examples/slice.py b/examples/slice.py
index 3e43443e..242a16cc 100644
--- a/examples/slice.py
+++ b/examples/slice.py
@@ -2,6 +2,7 @@
 1D slices
 =========
 """
+
 import napari
 
 viewer = napari.Viewer()
diff --git a/pyproject.toml b/pyproject.toml
index 7c7dbbdd..f76831a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,34 @@
 [build-system]
-requires = ["setuptools", "wheel", "setuptools_scm"]
+requires = ["setuptools", "setuptools_scm"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools_scm]
 write_to = "src/napari_matplotlib/_version.py"
 
 [tool.pytest.ini_options]
-qt_api = "pyqt6"
-addopts = "--mpl"
 filterwarnings = [
     "error",
+    "ignore:(?s).*Pyarrow will become a required dependency of pandas",
     # Coming from vispy
     "ignore:distutils Version classes are deprecated:DeprecationWarning",
     "ignore:`np.bool8` is a deprecated alias for `np.bool_`:DeprecationWarning",
+    # Coming from pydantic via napari
+    "ignore:Pickle, copy, and deepcopy support will be removed from itertools in Python 3.14.:DeprecationWarning",
+    # Until we stop supporting older numpy versions (<2.1)
+    "ignore:(?s).*`newshape` keyword argument is deprecated.*$:DeprecationWarning",
+]
+qt_api = "pyqt6"
+addopts = [
+    "--mpl",
+    "--mpl-baseline-relative",
+    "--strict-config",
+    "--strict-markers",
+    "-ra",
 ]
+minversion = "7"
+testpaths = ["src/napari_matplotlib/tests"]
+log_cli_level = "INFO"
+xfail_strict = true
 
 [tool.black]
 line-length = 79
@@ -23,8 +38,11 @@ profile = "black"
 line_length = 79
 
 [tool.ruff]
-target-version = "py38"
-select = ["I", "UP", "F", "E", "W", "D"]
+target-version = "py310"
+fix = true
+
+[tool.ruff.lint]
+select = ["B", "I", "UP", "F", "E", "W", "D"]
 ignore = [
     "D100", # Missing docstring in public module
     "D104", # Missing docstring in public package
@@ -34,37 +52,25 @@ ignore = [
     "D401", # First line of docstring should be in imperative mood
 
 ]
-fix = true
 
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
 "docs/*" = ["D"]
 "examples/*" = ["D"]
 "src/napari_matplotlib/tests/*" = ["D"]
 
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
 convention = "numpy"
 
 [tool.mypy]
-python_version = "3.8"
+python_version = "3.12"
 # Block below are checks that form part of mypy 'strict' mode
-warn_unused_configs = true
-warn_redundant_casts = true
-warn_unused_ignores = true
-strict_equality = true
-strict_concatenate = true
-check_untyped_defs = true
+strict = true
 disallow_subclassing_any = false # TODO: fix
-disallow_untyped_decorators = true
-disallow_any_generics = true
-disallow_untyped_calls = true
-disallow_incomplete_defs = true
-disallow_untyped_defs = true
-no_implicit_reexport = true
-warn_return_any = false # TODO: fix
+warn_return_any = false          # TODO: fix
 ignore_missing_imports = true
 
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+
 [[tool.mypy.overrides]]
-module = [
-    "napari_matplotlib/tests/*",
-]
+module = ["napari_matplotlib/tests/*"]
 disallow_untyped_defs = false
diff --git a/setup.cfg b/setup.cfg
index 92f4c8e1..a3709e66 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [metadata]
-name = napari_matplotlib
+name = napari-matplotlib
 description = A plugin to use Matplotlib with napari
 long_description = file: README.md
 long_description_content_type = text/markdown
@@ -9,7 +9,7 @@ author_email = d.stansby@ucl.ac.uk
 license = BSD-3-Clause
 license_files = LICENSE
 classifiers =
-    Development Status :: 3 - Alpha
+    Development Status :: 5 - Production/Stable
     Framework :: napari
     Intended Audience :: Developers
     License :: OSI Approved :: BSD License
@@ -20,7 +20,7 @@ classifiers =
     Topic :: Software Development :: Testing
 project_urls =
     Bug Tracker = https://github.com/matplotlib/napari-matplotlib/issues
-    Documentation = https://github.com/matplotlib/napari-matplotlib#README.md
+    Documentation = https://napari-matplotlib.github.io
     Source Code = https://github.com/matplotlib/napari-matplotlib
     User Support = https://github.com/matplotlib/napari-matplotlib/issues
 
@@ -28,10 +28,10 @@ project_urls =
 packages = find:
 install_requires =
     matplotlib
-    napari
-    numpy
+    napari>=0.5
+    numpy>=1.23
     tinycss2
-python_requires = >=3.8
+python_requires = >=3.10
 include_package_data = True
 package_dir =
     =src
@@ -49,13 +49,13 @@ napari.manifest =
 docs =
     napari[all]
     numpydoc
+    pydantic<2
     pydata-sphinx-theme
-    qtgallery
     sphinx
     sphinx-automodapi
     sphinx-gallery
 testing =
-    napari[pyqt6-experimental]
+    napari[pyqt6_experimental]>=0.4.18
     pooch
     pyqt6
     pytest
diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py
index 89c6d3de..ca69a548 100644
--- a/src/napari_matplotlib/base.py
+++ b/src/napari_matplotlib/base.py
@@ -1,18 +1,19 @@
 import os
 from pathlib import Path
-from typing import List, Optional, Tuple
 
 import matplotlib.style as mplstyle
 import napari
-from matplotlib.backends.backend_qtagg import (
-    FigureCanvas,
+from matplotlib.backends.backend_qtagg import (  # type: ignore[attr-defined]
+    FigureCanvasQTAgg,
     NavigationToolbar2QT,
 )
 from matplotlib.figure import Figure
+from napari.utils.events import Event
+from napari.utils.theme import get_theme
 from qtpy.QtGui import QIcon
 from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
 
-from .util import Interval, from_napari_css_get_size_of
+from .util import Interval, from_napari_css_get_size_of, style_sheet_from_theme
 
 __all__ = ["BaseNapariMPLWidget", "NapariMPLWidget", "SingleAxesWidget"]
 
@@ -36,25 +37,21 @@ class BaseNapariMPLWidget(QWidget):
     def __init__(
         self,
         napari_viewer: napari.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         super().__init__(parent=parent)
         self.viewer = napari_viewer
-        self._mpl_style_sheet_path: Optional[Path] = None
+        self.napari_theme_style_sheet = style_sheet_from_theme(
+            get_theme(napari_viewer.theme)
+        )
 
         # Sets figure.* style
-        with mplstyle.context(self.mpl_style_sheet_path):
-            self.canvas = FigureCanvas()
+        with mplstyle.context(self.napari_theme_style_sheet):
+            self.canvas = FigureCanvasQTAgg()  # type: ignore[no-untyped-call]
 
-        self.canvas.figure.patch.set_facecolor("none")
         self.canvas.figure.set_layout_engine("constrained")
-        self.toolbar = NapariNavigationToolbar(
-            self.canvas, parent=self
-        )  # type: ignore[no-untyped-call]
+        self.toolbar = NapariNavigationToolbar(self.canvas, parent=self)
         self._replace_toolbar_icons()
-        # callback to update when napari theme changed
-        # TODO: this isn't working completely (see issue #140)
-        # most of our styling respects the theme change but not all
         self.viewer.events.theme.connect(self._on_napari_theme_changed)
 
         self.setLayout(QVBoxLayout())
@@ -66,22 +63,6 @@ def figure(self) -> Figure:
         """Matplotlib figure."""
         return self.canvas.figure
 
-    @property
-    def mpl_style_sheet_path(self) -> Path:
-        """
-        Path to the set Matplotlib style sheet.
-        """
-        if self._mpl_style_sheet_path is not None:
-            return self._mpl_style_sheet_path
-        elif self._napari_theme_has_light_bg():
-            return Path(__file__).parent / "styles" / "light.mplstyle"
-        else:
-            return Path(__file__).parent / "styles" / "dark.mplstyle"
-
-    @mpl_style_sheet_path.setter
-    def mpl_style_sheet_path(self, path: Path) -> None:
-        self._mpl_style_sheet_path = Path(path)
-
     def add_single_axes(self) -> None:
         """
         Add a single Axes to the figure.
@@ -90,13 +71,21 @@ def add_single_axes(self) -> None:
         """
         # Sets axes.* style.
         # Does not set any text styling set by axes.* keys
-        with mplstyle.context(self.mpl_style_sheet_path):
-            self.axes = self.figure.subplots()
+        with mplstyle.context(self.napari_theme_style_sheet):
+            self.axes = self.figure.add_subplot()
 
-    def _on_napari_theme_changed(self) -> None:
+    def _on_napari_theme_changed(self, event: Event) -> None:
         """
         Called when the napari theme is changed.
+
+        Parameters
+        ----------
+        event : napari.utils.events.Event
+            Event that triggered the callback.
         """
+        self.napari_theme_style_sheet = style_sheet_from_theme(
+            get_theme(event.value)
+        )
         self._replace_toolbar_icons()
 
     def _napari_theme_has_light_bg(self) -> bool:
@@ -108,7 +97,7 @@ def _napari_theme_has_light_bg(self) -> bool:
         bool
             True if theme's background colour has hsl lighter than 50%, False if darker.
         """
-        theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False)
+        theme = napari.utils.theme.get_theme(self.viewer.theme)
         _, _, bg_lightness = theme.background.as_hsl_tuple()
         return bg_lightness > 0.5
 
@@ -178,16 +167,16 @@ class NapariMPLWidget(BaseNapariMPLWidget):
     #: Number of layers taken as input
     n_layers_input = Interval(None, None)
     #: Type of layer taken as input
-    input_layer_types: Tuple[napari.layers.Layer, ...] = (napari.layers.Layer,)
+    input_layer_types: tuple[napari.layers.Layer, ...] = (napari.layers.Layer,)
 
     def __init__(
         self,
         napari_viewer: napari.viewer.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         super().__init__(napari_viewer=napari_viewer, parent=parent)
         self._setup_callbacks()
-        self.layers: List[napari.layers.Layer] = []
+        self.layers: list[napari.layers.Layer] = []
 
         helper_text = self.n_layers_input._helper_text
         if helper_text is not None:
@@ -207,15 +196,18 @@ def current_z(self) -> int:
         """
         return self.viewer.dims.current_step[0]
 
-    def _on_napari_theme_changed(self) -> None:
+    def _on_napari_theme_changed(self, event: Event) -> None:
         """Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed.
 
-        Note:
-            At the moment we only handle the default 'light' and 'dark' napari themes.
+        Parameters
+        ----------
+        event : napari.utils.events.Event
+            Event that triggered the callback.
         """
-        super()._on_napari_theme_changed()
-        self.clear()
-        self.draw()
+        super()._on_napari_theme_changed(event)
+        # use self._draw instead of self.draw to cope with redraw while there are no
+        # layers, this makes the self.clear() obsolete
+        self._draw()
 
     def _setup_callbacks(self) -> None:
         """
@@ -232,6 +224,15 @@ def _setup_callbacks(self) -> None:
             self._update_layers
         )
 
+    @property
+    def _valid_layer_selection(self) -> bool:
+        """
+        Return `True` if layer selection is valid.
+        """
+        return self.n_selected_layers in self.n_layers_input and all(
+            isinstance(layer, self.input_layer_types) for layer in self.layers
+        )
+
     def _update_layers(self, event: napari.utils.events.Event) -> None:
         """
         Update the ``layers`` attribute with currently selected layers and re-draw.
@@ -239,7 +240,8 @@ def _update_layers(self, event: napari.utils.events.Event) -> None:
         self.layers = list(self.viewer.layers.selection)
         self.layers = sorted(self.layers, key=lambda layer: layer.name)
         self.on_update_layers()
-        self._draw()
+        if self._valid_layer_selection:
+            self._draw()
 
     def _draw(self) -> None:
         """
@@ -248,13 +250,12 @@ def _draw(self) -> None:
         """
         # Clearing axes sets new defaults, so need to make sure style is applied when
         # this happens
-        with mplstyle.context(self.mpl_style_sheet_path):
+        with mplstyle.context(self.napari_theme_style_sheet):
+            # everything should be done in the style context
             self.clear()
-        if self.n_selected_layers in self.n_layers_input and all(
-            isinstance(layer, self.input_layer_types) for layer in self.layers
-        ):
-            self.draw()
-        self.canvas.draw()
+            if self._valid_layer_selection:
+                self.draw()
+            self.canvas.draw()  # type: ignore[no-untyped-call]
 
     def clear(self) -> None:
         """
@@ -287,7 +288,7 @@ class SingleAxesWidget(NapariMPLWidget):
     def __init__(
         self,
         napari_viewer: napari.viewer.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         super().__init__(napari_viewer=napari_viewer, parent=parent)
         self.add_single_axes()
@@ -296,15 +297,15 @@ def clear(self) -> None:
         """
         Clear the axes.
         """
-        with mplstyle.context(self.mpl_style_sheet_path):
+        with mplstyle.context(self.napari_theme_style_sheet):
             self.axes.clear()
 
 
 class NapariNavigationToolbar(NavigationToolbar2QT):
     """Custom Toolbar style for Napari."""
 
-    def __init__(self, *args, **kwargs):  # type: ignore[no-untyped-def]
-        super().__init__(*args, **kwargs)
+    def __init__(self, *args, **kwargs) -> None:  # type: ignore[no-untyped-def]
+        super().__init__(*args, **kwargs)  # type: ignore[no-untyped-call]
         self.setIconSize(
             from_napari_css_get_size_of(
                 "QtViewerPushButton", fallback=(28, 28)
@@ -313,7 +314,7 @@ def __init__(self, *args, **kwargs):  # type: ignore[no-untyped-def]
 
     def _update_buttons_checked(self) -> None:
         """Update toggle tool icons when selected/unselected."""
-        super()._update_buttons_checked()
+        super()._update_buttons_checked()  # type: ignore[no-untyped-call]
         icon_dir = self.parentWidget()._get_path_to_icon()
 
         # changes pan/zoom icons depending on state (checked or not)
diff --git a/src/napari_matplotlib/features.py b/src/napari_matplotlib/features.py
new file mode 100644
index 00000000..34abf104
--- /dev/null
+++ b/src/napari_matplotlib/features.py
@@ -0,0 +1,9 @@
+from napari.layers import Labels, Points, Shapes, Tracks, Vectors
+
+FEATURES_LAYER_TYPES = (
+    Labels,
+    Points,
+    Shapes,
+    Tracks,
+    Vectors,
+)
diff --git a/src/napari_matplotlib/histogram.py b/src/napari_matplotlib/histogram.py
index 39ad41a3..85bba9d2 100644
--- a/src/napari_matplotlib/histogram.py
+++ b/src/napari_matplotlib/histogram.py
@@ -1,17 +1,58 @@
-from typing import Optional
+from typing import Any, cast
 
 import napari
 import numpy as np
-from qtpy.QtWidgets import QWidget
+import numpy.typing as npt
+from matplotlib.container import BarContainer
+from napari.layers import Image
+from napari.layers._multiscale_data import MultiScaleData
+from qtpy.QtWidgets import (
+    QComboBox,
+    QFormLayout,
+    QGroupBox,
+    QLabel,
+    QSpinBox,
+    QVBoxLayout,
+    QWidget,
+)
 
 from .base import SingleAxesWidget
+from .features import FEATURES_LAYER_TYPES
 from .util import Interval
 
-__all__ = ["HistogramWidget"]
+__all__ = ["HistogramWidget", "FeaturesHistogramWidget"]
 
 _COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}
 
 
+def _get_bins(
+    data: npt.NDArray[Any],
+    num_bins: int = 100,
+) -> npt.NDArray[np.floating]:
+    """Create evenly spaced bins with a given interval.
+
+    Parameters
+    ----------
+    data : napari.layers.Layer.data
+        Napari layer data.
+    num_bins : integer, optional
+        Number of evenly-spaced bins to create. Defaults to 100.
+
+    Returns
+    -------
+    bin_edges : numpy.ndarray
+        Array of evenly spaced bin edges.
+    """
+    if data.dtype.kind in {"i", "u"}:
+        # Make sure integer data types have integer sized bins
+        step = np.ceil(np.ptp(data) / num_bins)
+        return np.arange(np.min(data), np.max(data) + step, step)
+    else:
+        # For other data types we can use exactly `num_bins` bins
+        # (and `num_bins` + 1 bin edges)
+        return np.linspace(np.min(data), np.max(data), num_bins + 1)
+
+
 class HistogramWidget(SingleAxesWidget):
     """
     Display a histogram of the currently selected layer.
@@ -23,36 +64,251 @@ class HistogramWidget(SingleAxesWidget):
     def __init__(
         self,
         napari_viewer: napari.viewer.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         super().__init__(napari_viewer, parent=parent)
+
+        num_bins_widget = QSpinBox()
+        num_bins_widget.setRange(1, 100_000)
+        num_bins_widget.setValue(101)
+        num_bins_widget.setWrapping(False)
+        num_bins_widget.setKeyboardTracking(False)
+
+        # Set bins widget layout
+        bins_selection_layout = QFormLayout()
+        bins_selection_layout.addRow("num bins", num_bins_widget)
+
+        # Group the widgets and add to main layout
+        params_widget_group = QGroupBox("Params")
+        params_widget_group_layout = QVBoxLayout()
+        params_widget_group_layout.addLayout(bins_selection_layout)
+        params_widget_group.setLayout(params_widget_group_layout)
+        self.layout().addWidget(params_widget_group)
+
+        # Add callbacks
+        num_bins_widget.valueChanged.connect(self._draw)
+
+        # Store widgets for later usage
+        self.num_bins_widget = num_bins_widget
+
         self._update_layers(None)
+        self.viewer.events.theme.connect(self._on_napari_theme_changed)
 
-    def draw(self) -> None:
+    def on_update_layers(self) -> None:
         """
-        Clear the axes and histogram the currently selected layer/slice.
+        Called when the selected layers are updated.
         """
-        layer = self.layers[0]
-        bins = np.linspace(np.min(layer.data), np.max(layer.data), 100)
+        super().on_update_layers()
+        if self._valid_layer_selection:
+            self.layers[0].events.contrast_limits.connect(
+                self._update_contrast_lims
+            )
+
+        if not self.layers:
+            return
+
+        # Reset the num bins based on new layer data
+        layer_data = self._get_layer_data(self.layers[0])
+        self._set_widget_nums_bins(data=layer_data)
+
+    def _update_contrast_lims(self) -> None:
+        for lim, line in zip(
+            self.layers[0].contrast_limits, self._contrast_lines, strict=False
+        ):
+            line.set_xdata(lim)
+
+        self.figure.canvas.draw()
+
+    def _set_widget_nums_bins(self, data: npt.NDArray[Any]) -> None:
+        """Update num_bins widget with bins determined from the image data"""
+        bins = _get_bins(data)
+        self.num_bins_widget.setValue(bins.size - 1)
+
+    def _get_layer_data(self, layer: napari.layers.Layer) -> npt.NDArray[Any]:
+        """Get the data associated with a given layer"""
+        data = layer.data
 
-        if layer.data.ndim - layer.rgb == 3:
+        if isinstance(layer.data, MultiScaleData):
+            data = data[layer.data_level]
+
+        if layer.ndim - layer.rgb == 3:
             # 3D data, can be single channel or RGB
-            data = layer.data[self.current_z]
+            # Slice in z dimension
+            data = data[self.current_z]
             self.axes.set_title(f"z={self.current_z}")
-        else:
-            data = layer.data
+
+        # Read data into memory if it's a dask array
+        data = np.asarray(data)
+
+        return data
+
+    def draw(self) -> None:
+        """
+        Clear the axes and histogram the currently selected layer/slice.
+        """
+        layer: Image = self.layers[0]
+        data = self._get_layer_data(layer)
+
+        # Important to calculate bins after slicing 3D data, to avoid reading
+        # whole cube into memory.
+        bins = _get_bins(
+            data,
+            num_bins=self.num_bins_widget.value(),
+        )
 
         if layer.rgb:
             # Histogram RGB channels independently
             for i, c in enumerate("rgb"):
                 self.axes.hist(
                     data[..., i].ravel(),
-                    bins=bins,
+                    bins=bins.tolist(),
                     label=c,
                     histtype="step",
                     color=_COLORS[c],
                 )
         else:
-            self.axes.hist(data.ravel(), bins=bins, label=layer.name)
+            self.axes.hist(data.ravel(), bins=bins.tolist(), label=layer.name)
 
+        self._contrast_lines = [
+            self.axes.axvline(lim, color="white")
+            for lim in layer.contrast_limits
+        ]
         self.axes.legend()
+
+
+class FeaturesHistogramWidget(SingleAxesWidget):
+    """
+    Display a histogram of selected feature attached to selected layer.
+    """
+
+    n_layers_input = Interval(1, 1)
+    # All layers that have a .features attributes
+    input_layer_types = FEATURES_LAYER_TYPES
+
+    def __init__(
+        self,
+        napari_viewer: napari.viewer.Viewer,
+        parent: QWidget | None = None,
+    ):
+        super().__init__(napari_viewer, parent=parent)
+
+        self.layout().addLayout(QVBoxLayout())
+        self._key_selection_widget = QComboBox()
+        self.layout().addWidget(QLabel("Key:"))
+        self.layout().addWidget(self._key_selection_widget)
+
+        self._key_selection_widget.currentTextChanged.connect(
+            self._set_axis_keys
+        )
+
+        self._update_layers(None)
+
+    @property
+    def x_axis_key(self) -> str | None:
+        """Key to access x axis data from the FeaturesTable"""
+        return self._x_axis_key
+
+    @x_axis_key.setter
+    def x_axis_key(self, key: str | None) -> None:
+        self._x_axis_key = key
+        self._draw()
+
+    def _set_axis_keys(self, x_axis_key: str) -> None:
+        """Set both axis keys and then redraw the plot"""
+        self._x_axis_key = x_axis_key
+        self._draw()
+
+    def _get_valid_axis_keys(self) -> list[str]:
+        """
+        Get the valid axis keys from the layer FeatureTable.
+
+        Returns
+        -------
+        axis_keys : List[str]
+            The valid axis keys in the FeatureTable. If the table is empty
+            or there isn't a table, returns an empty list.
+        """
+        if len(self.layers) == 0 or not (hasattr(self.layers[0], "features")):
+            return []
+        else:
+            return self.layers[0].features.keys()
+
+    def _get_data(self) -> tuple[npt.NDArray[Any] | None, str]:
+        """Get the plot data.
+
+        Returns
+        -------
+        data : List[np.ndarray]
+            List contains X and Y columns from the FeatureTable. Returns
+            an empty array if nothing to plot.
+        x_axis_name : str
+            The title to display on the x axis. Returns
+            an empty string if nothing to plot.
+        """
+        if not hasattr(self.layers[0], "features"):
+            # if the selected layer doesn't have a featuretable,
+            # skip draw
+            return None, ""
+
+        feature_table = self.layers[0].features
+
+        if (len(feature_table) == 0) or (self.x_axis_key is None):
+            return None, ""
+
+        data = feature_table[self.x_axis_key]
+        x_axis_name = self.x_axis_key.replace("_", " ")
+
+        return data, x_axis_name
+
+    def on_update_layers(self) -> None:
+        """
+        Called when the layer selection changes by ``self.update_layers()``.
+        """
+        # reset the axis keys
+        self._x_axis_key = None
+
+        # Clear combobox
+        self._key_selection_widget.clear()
+        self._key_selection_widget.addItems(self._get_valid_axis_keys())
+
+    def draw(self) -> None:
+        """Clear the axes and histogram the currently selected layer/slice."""
+        # get the colormap from the layer depending on its type
+        if isinstance(self.layers[0], napari.layers.Points):
+            colormap = self.layers[0].face_colormap
+            if self.x_axis_key:
+                self.layers[0].face_color = self.x_axis_key
+        elif isinstance(self.layers[0], napari.layers.Vectors):
+            colormap = self.layers[0].edge_colormap
+            if self.x_axis_key:
+                self.layers[0].edge_color = self.x_axis_key
+        else:
+            colormap = None
+
+        # apply new colors to the layer
+        self.viewer.layers[self.layers[0].name].refresh_colors(True)
+        self.viewer.layers[self.layers[0].name].refresh()
+
+        # Draw the histogram
+        data, x_axis_name = self._get_data()
+
+        if data is None:
+            return
+
+        bins = _get_bins(data)
+
+        _, bins, patches = self.axes.hist(data, bins=bins.tolist())
+        patches = cast(BarContainer, patches)
+
+        # recolor the histogram plot
+        if colormap is not None:
+            self.bins_norm = (bins - bins.min()) / (bins.max() - bins.min())
+            colors = colormap.map(self.bins_norm)
+
+            # Set histogram style:
+            for idx, patch in enumerate(patches):
+                patch.set_facecolor(colors[idx])
+
+        # set ax labels
+        self.axes.set_xlabel(x_axis_name)
+        self.axes.set_ylabel("Counts [#]")
diff --git a/src/napari_matplotlib/napari.yaml b/src/napari_matplotlib/napari.yaml
index b736592b..71af0ca6 100644
--- a/src/napari_matplotlib/napari.yaml
+++ b/src/napari_matplotlib/napari.yaml
@@ -14,6 +14,10 @@ contributions:
       python_name: napari_matplotlib:FeaturesScatterWidget
       title: Make a scatter plot of layer features
 
+    - id: napari-matplotlib.features_histogram
+      python_name: napari_matplotlib:FeaturesHistogramWidget
+      title: Plot feature histograms
+
     - id: napari-matplotlib.slice
       python_name: napari_matplotlib:SliceWidget
       title: Plot a 1D slice
@@ -28,5 +32,8 @@ contributions:
     - command: napari-matplotlib.features_scatter
       display_name: FeaturesScatter
 
+    - command: napari-matplotlib.features_histogram
+      display_name: FeaturesHistogram
+
     - command: napari-matplotlib.slice
       display_name: 1D slice
diff --git a/src/napari_matplotlib/scatter.py b/src/napari_matplotlib/scatter.py
index db86c7f3..98ebe928 100644
--- a/src/napari_matplotlib/scatter.py
+++ b/src/napari_matplotlib/scatter.py
@@ -1,10 +1,11 @@
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import Any
 
 import napari
 import numpy.typing as npt
 from qtpy.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget
 
 from .base import SingleAxesWidget
+from .features import FEATURES_LAYER_TYPES
 from .util import Interval
 
 __all__ = ["ScatterBaseWidget", "ScatterWidget", "FeaturesScatterWidget"]
@@ -39,7 +40,7 @@ def draw(self) -> None:
         self.axes.set_xlabel(x_axis_name)
         self.axes.set_ylabel(y_axis_name)
 
-    def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+    def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
         """
         Get the plot data.
 
@@ -66,7 +67,7 @@ class ScatterWidget(ScatterBaseWidget):
     n_layers_input = Interval(2, 2)
     input_layer_types = (napari.layers.Image,)
 
-    def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+    def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
         """
         Get the plot data.
 
@@ -94,24 +95,18 @@ class FeaturesScatterWidget(ScatterBaseWidget):
 
     n_layers_input = Interval(1, 1)
     # All layers that have a .features attributes
-    input_layer_types = (
-        napari.layers.Labels,
-        napari.layers.Points,
-        napari.layers.Shapes,
-        napari.layers.Tracks,
-        napari.layers.Vectors,
-    )
+    input_layer_types = FEATURES_LAYER_TYPES
 
     def __init__(
         self,
         napari_viewer: napari.viewer.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         super().__init__(napari_viewer, parent=parent)
 
         self.layout().addLayout(QVBoxLayout())
 
-        self._selectors: Dict[str, QComboBox] = {}
+        self._selectors: dict[str, QComboBox] = {}
         for dim in ["x", "y"]:
             self._selectors[dim] = QComboBox()
             # Re-draw when combo boxes are updated
@@ -123,7 +118,7 @@ def __init__(
         self._update_layers(None)
 
     @property
-    def x_axis_key(self) -> Union[str, None]:
+    def x_axis_key(self) -> str | None:
         """
         Key for the x-axis data.
         """
@@ -138,7 +133,7 @@ def x_axis_key(self, key: str) -> None:
         self._draw()
 
     @property
-    def y_axis_key(self) -> Union[str, None]:
+    def y_axis_key(self) -> str | None:
         """
         Key for the y-axis data.
         """
@@ -152,7 +147,7 @@ def y_axis_key(self, key: str) -> None:
         self._selectors["y"].setCurrentText(key)
         self._draw()
 
-    def _get_valid_axis_keys(self) -> List[str]:
+    def _get_valid_axis_keys(self) -> list[str]:
         """
         Get the valid axis keys from the layer FeatureTable.
 
@@ -191,7 +186,7 @@ def draw(self) -> None:
         if self._ready_to_scatter():
             super().draw()
 
-    def _get_data(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
+    def _get_data(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any], str, str]:
         """
         Get the plot data from the ``features`` attribute of the first
         selected layer.
diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py
index e3aa80b2..1924bf2b 100644
--- a/src/napari_matplotlib/slice.py
+++ b/src/napari_matplotlib/slice.py
@@ -1,19 +1,23 @@
-from typing import Any, Dict, Optional, Tuple
+from typing import Any
 
 import matplotlib.ticker as mticker
 import napari
 import numpy as np
 import numpy.typing as npt
-from qtpy.QtWidgets import QComboBox, QHBoxLayout, QLabel, QSpinBox, QWidget
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import (
+    QComboBox,
+    QLabel,
+    QSlider,
+    QVBoxLayout,
+    QWidget,
+)
 
 from .base import SingleAxesWidget
 from .util import Interval
 
 __all__ = ["SliceWidget"]
 
-_dims_sel = ["x", "y"]
-_dims = ["x", "y", "z"]
-
 
 class SliceWidget(SingleAxesWidget):
     """
@@ -26,33 +30,51 @@ class SliceWidget(SingleAxesWidget):
     def __init__(
         self,
         napari_viewer: napari.viewer.Viewer,
-        parent: Optional[QWidget] = None,
+        parent: QWidget | None = None,
     ):
         # Setup figure/axes
         super().__init__(napari_viewer, parent=parent)
 
-        button_layout = QHBoxLayout()
-        self.layout().addLayout(button_layout)
-
         self.dim_selector = QComboBox()
+        self.dim_selector.addItems(["x", "y"])
+
+        self.slice_selector = QSlider(orientation=Qt.Orientation.Horizontal)
+
+        # Create widget layout
+        button_layout = QVBoxLayout()
         button_layout.addWidget(QLabel("Slice axis:"))
         button_layout.addWidget(self.dim_selector)
-        self.dim_selector.addItems(_dims)
-
-        self.slice_selectors = {}
-        for d in _dims_sel:
-            self.slice_selectors[d] = QSpinBox()
-            button_layout.addWidget(QLabel(f"{d}:"))
-            button_layout.addWidget(self.slice_selectors[d])
+        button_layout.addWidget(self.slice_selector)
+        self.layout().addLayout(button_layout)
 
         # Setup callbacks
-        # Re-draw when any of the combon/spin boxes are updated
+        # Re-draw when any of the combo/slider is updated
         self.dim_selector.currentTextChanged.connect(self._draw)
-        for d in _dims_sel:
-            self.slice_selectors[d].textChanged.connect(self._draw)
+        self.slice_selector.valueChanged.connect(self._draw)
 
         self._update_layers(None)
 
+    def on_update_layers(self) -> None:
+        """
+        Called when layer selection is updated.
+        """
+        if not len(self.layers):
+            return
+        if self.current_dim_name == "x":
+            max = self._layer.data.shape[-2]
+        elif self.current_dim_name == "y":
+            max = self._layer.data.shape[-1]
+        else:
+            raise RuntimeError("dim name must be x or y")
+        self.slice_selector.setRange(0, max - 1)
+
+    @property
+    def _slice_width(self) -> int:
+        """
+        Width of the slice being plotted.
+        """
+        return self._layer.data.shape[self.current_dim_index]
+
     @property
     def _layer(self) -> napari.layers.Layer:
         """
@@ -61,7 +83,7 @@ def _layer(self) -> napari.layers.Layer:
         return self.layers[0]
 
     @property
-    def current_dim(self) -> str:
+    def current_dim_name(self) -> str:
         """
         Currently selected slice dimension.
         """
@@ -74,36 +96,40 @@ def current_dim_index(self) -> int:
         """
         # Note the reversed list because in napari the z-axis is the first
         # numpy axis
-        return _dims[::-1].index(self.current_dim)
+        return self._dim_names.index(self.current_dim_name)
 
     @property
-    def _selector_values(self) -> Dict[str, int]:
+    def _dim_names(self) -> list[str]:
         """
-        Values of the slice selectors.
+        List of dimension names. This is a property as it varies depending on the
+        dimensionality of the currently selected data.
         """
-        return {d: self.slice_selectors[d].value() for d in _dims_sel}
-
-    def _get_xy(self) -> Tuple[npt.NDArray[Any], npt.NDArray[Any]]:
+        if self._layer.data.ndim == 2:
+            return ["y", "x"]
+        elif self._layer.data.ndim == 3:
+            return ["z", "y", "x"]
+        else:
+            raise RuntimeError("Don't know how to handle ndim != 2 or 3")
+
+    def _get_xy(self) -> tuple[npt.NDArray[Any], npt.NDArray[Any]]:
         """
         Get data for plotting.
         """
-        x = np.arange(self._layer.data.shape[self.current_dim_index])
-
-        vals = self._selector_values
-        vals.update({"z": self.current_z})
+        val = self.slice_selector.value()
 
         slices = []
-        for d in _dims:
-            if d == self.current_dim:
+        for dim_name in self._dim_names:
+            if dim_name == self.current_dim_name:
                 # Select all data along this axis
                 slices.append(slice(None))
+            elif dim_name == "z":
+                # Only select the currently viewed z-index
+                slices.append(slice(self.current_z, self.current_z + 1))
             else:
                 # Select specific index
-                val = vals[d]
                 slices.append(slice(val, val + 1))
 
-        # Reverse since z is the first axis in napari
-        slices = slices[::-1]
+        x = np.arange(self._slice_width)
         y = self._layer.data[tuple(slices)].ravel()
 
         return x, y
@@ -115,7 +141,7 @@ def draw(self) -> None:
         x, y = self._get_xy()
 
         self.axes.plot(x, y)
-        self.axes.set_xlabel(self.current_dim)
+        self.axes.set_xlabel(self.current_dim_name)
         self.axes.set_title(self._layer.name)
         # Make sure all ticks lie on integer values
         self.axes.xaxis.set_major_locator(
diff --git a/src/napari_matplotlib/styles/README.md b/src/napari_matplotlib/styles/README.md
deleted file mode 100644
index 79d3c417..00000000
--- a/src/napari_matplotlib/styles/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-This folder contains default built-in Matplotlib style sheets.
-See https://matplotlib.org/stable/tutorials/introductory/customizing.html#defining-your-own-style
-for more info on Matplotlib style sheets.
diff --git a/src/napari_matplotlib/styles/dark.mplstyle b/src/napari_matplotlib/styles/dark.mplstyle
deleted file mode 100644
index 1658f9b4..00000000
--- a/src/napari_matplotlib/styles/dark.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# Dark-theme napari colour scheme for matplotlib plots
-
-# text (very light grey - almost white): #f0f1f2
-# foreground (mid grey):                 #414851
-# background (dark blue-gray):           #262930
-
-figure.facecolor    : none
-axes.labelcolor     : f0f1f2
-axes.facecolor      : none
-axes.edgecolor      : 414851
-xtick.color         : f0f1f2
-ytick.color         : f0f1f2
diff --git a/src/napari_matplotlib/styles/light.mplstyle b/src/napari_matplotlib/styles/light.mplstyle
deleted file mode 100644
index 3b8d7d1d..00000000
--- a/src/napari_matplotlib/styles/light.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# Light-theme napari colour scheme for matplotlib plots
-
-# text (very dark grey - almost black): #3b3a39
-# foreground (mid grey):                #d6d0ce
-# background (brownish beige):          #efebe9
-
-figure.facecolor    : none
-axes.labelcolor     : 3b3a39
-axes.facecolor      : none
-axes.edgecolor      : d6d0ce
-xtick.color         : 3b3a39
-ytick.color         : 3b3a39
diff --git a/src/napari_matplotlib/tests/baseline/test_custom_theme.png b/src/napari_matplotlib/tests/baseline/test_custom_theme.png
index 65c43a49..ffa4635b 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_custom_theme.png and b/src/napari_matplotlib/tests/baseline/test_custom_theme.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram.png
new file mode 100644
index 00000000..1892af44
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png
new file mode 100644
index 00000000..88a28f79
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png
new file mode 100644
index 00000000..857d9344
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png
index b76d1e10..b9096e4d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png
new file mode 100644
index 00000000..98e3cde1
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png
index 2dffdcb2..ec4ad96d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_2D.png b/src/napari_matplotlib/tests/baseline/test_slice_2D.png
index 5b73091c..c1e67637 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_2D.png and b/src/napari_matplotlib/tests/baseline/test_slice_2D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_3D.png b/src/napari_matplotlib/tests/baseline/test_slice_3D.png
index 43c8c3b6..046293f3 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_3D.png and b/src/napari_matplotlib/tests/baseline/test_slice_3D.png differ
diff --git a/src/napari_matplotlib/tests/data/test_theme.mplstyle b/src/napari_matplotlib/tests/data/test_theme.mplstyle
deleted file mode 100644
index 2f94b31f..00000000
--- a/src/napari_matplotlib/tests/data/test_theme.mplstyle
+++ /dev/null
@@ -1,15 +0,0 @@
-# Dark-theme napari colour scheme for matplotlib plots
-
-#f4b8b2 # light red
-#b2e4f4 # light blue
-#0aa3fc # dark blue
-#008939 # dark green
-
-figure.facecolor    : f4b8b2 # light red
-axes.facecolor      : b2e4f4 # light blue
-axes.edgecolor      : 0aa3fc # dark blue
-
-xtick.color         : 008939 # dark green
-xtick.labelcolor    : 008939 # dark green
-ytick.color         : 008939 # dark green
-ytick.labelcolor    : 008939 # dark green
diff --git a/src/napari_matplotlib/tests/helpers.py b/src/napari_matplotlib/tests/helpers.py
index 15259f54..b00c0af4 100644
--- a/src/napari_matplotlib/tests/helpers.py
+++ b/src/napari_matplotlib/tests/helpers.py
@@ -13,7 +13,7 @@ def fig_to_array(fig: Figure) -> npt.NDArray[np.uint8]:
     with BytesIO() as io_buf:
         fig.savefig(io_buf, format="raw")
         io_buf.seek(0)
-        img_arr = np.reshape(
+        img_arr: npt.NDArray[np.uint8] = np.reshape(
             np.frombuffer(io_buf.getvalue(), dtype=np.uint8),
             newshape=(int(fig.bbox.bounds[3]), int(fig.bbox.bounds[2]), -1),
         )
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png
index 269ebd01..9237dbdc 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png
index 3b550666..a11bda5f 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png
index 27e7d673..cd42a8a2 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/test_scatter.py b/src/napari_matplotlib/tests/scatter/test_scatter.py
index a225863d..0c60660c 100644
--- a/src/napari_matplotlib/tests/scatter/test_scatter.py
+++ b/src/napari_matplotlib/tests/scatter/test_scatter.py
@@ -15,7 +15,9 @@ def test_scatter_2D(make_napari_viewer, astronaut_data):
     viewer.add_image(astronaut_data[0], **astronaut_data[1], name="astronaut")
 
     viewer.add_image(
-        astronaut_data[0] * -1, **astronaut_data[1], name="astronaut_reversed"
+        astronaut_data[0] * -1.0,
+        **astronaut_data[1],
+        name="astronaut_reversed",
     )
     # De-select existing selection
     viewer.layers.selection.clear()
@@ -36,7 +38,7 @@ def test_scatter_3D(make_napari_viewer, brain_data):
     viewer.add_image(brain_data[0], **brain_data[1], name="brain")
 
     viewer.add_image(
-        brain_data[0] * -1, **brain_data[1], name="brain_reversed"
+        brain_data[0] * -1.0, **brain_data[1], name="brain_reversed"
     )
     # De-select existing selection
     viewer.layers.selection.clear()
diff --git a/src/napari_matplotlib/tests/scatter/test_scatter_features.py b/src/napari_matplotlib/tests/scatter/test_scatter_features.py
index c211a064..3ede1e28 100644
--- a/src/napari_matplotlib/tests/scatter/test_scatter_features.py
+++ b/src/napari_matplotlib/tests/scatter/test_scatter_features.py
@@ -1,5 +1,5 @@
 from copy import deepcopy
-from typing import Any, Dict, Tuple
+from typing import Any
 
 import numpy as np
 import numpy.typing as npt
@@ -34,9 +34,9 @@ def test_features_scatter_widget_2D(
 
 
 def make_labels_layer_with_features() -> (
-    Tuple[npt.NDArray[np.uint16], Dict[str, Any]]
+    tuple[npt.NDArray[np.uint16], dict[str, Any]]
 ):
-    label_image = np.zeros((100, 100), dtype=np.uint16)
+    label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
     for label_value, start_index in enumerate([10, 30, 50], start=1):
         end_index = start_index + 10
         label_image[start_index:end_index, start_index:end_index] = label_value
diff --git a/src/napari_matplotlib/tests/test_histogram.py b/src/napari_matplotlib/tests/test_histogram.py
index 4d170014..435973ba 100644
--- a/src/napari_matplotlib/tests/test_histogram.py
+++ b/src/napari_matplotlib/tests/test_histogram.py
@@ -1,8 +1,27 @@
 from copy import deepcopy
 
+import numpy as np
 import pytest
 
-from napari_matplotlib import HistogramWidget
+from napari_matplotlib import FeaturesHistogramWidget, HistogramWidget
+from napari_matplotlib.tests.helpers import (
+    assert_figures_equal,
+    assert_figures_not_equal,
+)
+
+
+@pytest.mark.mpl_image_compare
+def test_histogram_2D_bins(make_napari_viewer, astronaut_data):
+    viewer = make_napari_viewer()
+    viewer.theme = "light"
+    viewer.add_image(astronaut_data[0], **astronaut_data[1])
+    widget = HistogramWidget(viewer)
+    viewer.window.add_dock_widget(widget)
+    widget.num_bins_widget.setValue(25)
+    fig = widget.figure
+    # Need to return a copy, as original figure is too eagerley garbage
+    # collected by the widget
+    return deepcopy(fig)
 
 
 @pytest.mark.mpl_image_compare
@@ -28,3 +47,108 @@ def test_histogram_3D(make_napari_viewer, brain_data):
     # Need to return a copy, as original figure is too eagerley garbage
     # collected by the widget
     return deepcopy(fig)
+
+
+def test_feature_histogram(make_napari_viewer):
+    n_points = 1000
+    random_points = np.random.random((n_points, 3)) * 10
+    random_directions = np.random.random((n_points, 3)) * 10
+    random_vectors = np.stack([random_points, random_directions], axis=1)
+    feature1 = np.random.random(n_points)
+    feature2 = np.random.normal(size=n_points)
+
+    viewer = make_napari_viewer()
+    viewer.add_points(
+        random_points,
+        properties={"feature1": feature1, "feature2": feature2},
+        name="points1",
+    )
+    viewer.add_vectors(
+        random_vectors,
+        properties={"feature1": feature1, "feature2": feature2},
+        name="vectors1",
+    )
+
+    widget = FeaturesHistogramWidget(viewer)
+    viewer.window.add_dock_widget(widget)
+
+    # Check whether changing the selected key changes the plot
+    widget._set_axis_keys("feature1")
+    fig1 = deepcopy(widget.figure)
+
+    widget._set_axis_keys("feature2")
+    assert_figures_not_equal(widget.figure, fig1)
+
+    # check whether selecting a different layer produces the same plot
+    viewer.layers.selection.clear()
+    viewer.layers.selection.add(viewer.layers[1])
+    assert_figures_equal(widget.figure, fig1)
+
+
+@pytest.mark.mpl_image_compare
+def test_feature_histogram_vectors(make_napari_viewer):
+    n_points = 1000
+    np.random.seed(42)
+    random_points = np.random.random((n_points, 3)) * 10
+    random_directions = np.random.random((n_points, 3)) * 10
+    random_vectors = np.stack([random_points, random_directions], axis=1)
+    feature1 = np.random.random(n_points)
+
+    viewer = make_napari_viewer()
+    viewer.add_vectors(
+        random_vectors,
+        properties={"feature1": feature1},
+        name="vectors1",
+    )
+
+    widget = FeaturesHistogramWidget(viewer)
+    viewer.window.add_dock_widget(widget)
+    widget._set_axis_keys("feature1")
+
+    fig = FeaturesHistogramWidget(viewer).figure
+    return deepcopy(fig)
+
+
+@pytest.mark.mpl_image_compare
+def test_feature_histogram_points(make_napari_viewer):
+    np.random.seed(0)
+    n_points = 1000
+    random_points = np.random.random((n_points, 3)) * 10
+    feature1 = np.random.random(n_points)
+
+    viewer = make_napari_viewer()
+    viewer.add_points(
+        random_points,
+        properties={"feature1": feature1},
+        name="points1",
+    )
+
+    widget = FeaturesHistogramWidget(viewer)
+    viewer.window.add_dock_widget(widget)
+    widget._set_axis_keys("feature1")
+
+    fig = FeaturesHistogramWidget(viewer).figure
+    return deepcopy(fig)
+
+
+def test_change_layer(make_napari_viewer, brain_data, astronaut_data):
+    viewer = make_napari_viewer()
+    widget = HistogramWidget(viewer)
+
+    viewer.add_image(brain_data[0], **brain_data[1])
+    viewer.add_image(astronaut_data[0], **astronaut_data[1])
+
+    # Select first layer
+    viewer.layers.selection.clear()
+    viewer.layers.selection.add(viewer.layers[0])
+    fig1 = deepcopy(widget.figure)
+
+    # Re-selecting first layer should produce identical plot
+    viewer.layers.selection.clear()
+    viewer.layers.selection.add(viewer.layers[0])
+    assert_figures_equal(widget.figure, fig1)
+
+    # Plotting the second layer should produce a different plot
+    viewer.layers.selection.clear()
+    viewer.layers.selection.add(viewer.layers[1])
+    assert_figures_not_equal(widget.figure, fig1)
diff --git a/src/napari_matplotlib/tests/test_layer_changes.py b/src/napari_matplotlib/tests/test_layer_changes.py
index bdd6c600..15958c07 100644
--- a/src/napari_matplotlib/tests/test_layer_changes.py
+++ b/src/napari_matplotlib/tests/test_layer_changes.py
@@ -1,5 +1,5 @@
 from copy import deepcopy
-from typing import Any, Dict, Tuple, Type
+from typing import Any
 
 import numpy as np
 import numpy.typing as npt
@@ -61,8 +61,8 @@ def test_change_features_layer(
 
 def assert_features_plot_changes(
     viewer: Viewer,
-    widget_cls: Type[NapariMPLWidget],
-    data: Tuple[npt.NDArray[np.generic], Dict[str, Any]],
+    widget_cls: type[NapariMPLWidget],
+    data: tuple[npt.NDArray[np.generic], dict[str, Any]],
 ) -> None:
     """
     When the selected layer is changed, make sure the plot generated
diff --git a/src/napari_matplotlib/tests/test_slice.py b/src/napari_matplotlib/tests/test_slice.py
index 412e71c3..368a7ded 100644
--- a/src/napari_matplotlib/tests/test_slice.py
+++ b/src/napari_matplotlib/tests/test_slice.py
@@ -9,9 +9,13 @@
 def test_slice_3D(make_napari_viewer, brain_data):
     viewer = make_napari_viewer()
     viewer.theme = "light"
-    viewer.add_image(brain_data[0], **brain_data[1])
+
+    data = brain_data[0]
+    assert data.ndim == 3, data.shape
+    viewer.add_image(data, **brain_data[1])
+
     axis = viewer.dims.last_used
-    slice_no = brain_data[0].shape[0] - 1
+    slice_no = data.shape[0] - 1
     viewer.dims.set_current_step(axis, slice_no)
     fig = SliceWidget(viewer).figure
     # Need to return a copy, as original figure is too eagerley garbage
@@ -23,8 +27,37 @@ def test_slice_3D(make_napari_viewer, brain_data):
 def test_slice_2D(make_napari_viewer, astronaut_data):
     viewer = make_napari_viewer()
     viewer.theme = "light"
-    viewer.add_image(astronaut_data[0], **astronaut_data[1])
+
+    # Take first RGB channel
+    data = astronaut_data[0][:, :, 0]
+    assert data.ndim == 2, data.shape
+    viewer.add_image(data)
+
     fig = SliceWidget(viewer).figure
     # Need to return a copy, as original figure is too eagerley garbage
     # collected by the widget
     return deepcopy(fig)
+
+
+def test_slice_axes(make_napari_viewer, astronaut_data):
+    viewer = make_napari_viewer()
+    viewer.theme = "light"
+
+    # Take first RGB channel
+    data = astronaut_data[0][:256, :, 0]
+    # Shape:
+    # x: 0 > 512
+    # y: 0 > 256
+    assert data.ndim == 2, data.shape
+    # Make sure data isn't square for later tests
+    assert data.shape[0] != data.shape[1]
+    viewer.add_image(data)
+
+    widget = SliceWidget(viewer)
+    assert widget._dim_names == ["y", "x"]
+    assert widget.current_dim_name == "x"
+    assert widget.slice_selector.value() == 0
+    assert widget.slice_selector.minimum() == 0
+    assert widget.slice_selector.maximum() == data.shape[0] - 1
+    # x/y are flipped in napari
+    assert widget._slice_width == data.shape[1]
diff --git a/src/napari_matplotlib/tests/test_theme.py b/src/napari_matplotlib/tests/test_theme.py
index 7af61f17..5fedc43d 100644
--- a/src/napari_matplotlib/tests/test_theme.py
+++ b/src/napari_matplotlib/tests/test_theme.py
@@ -1,5 +1,3 @@
-from copy import deepcopy
-
 import napari
 import numpy as np
 import pytest
@@ -31,10 +29,12 @@ def _mock_up_theme() -> None:
     Based on:
     https://napari.org/stable/gallery/new_theme.html
     """
-    blue_theme = napari.utils.theme.get_theme("dark", False)
-    blue_theme.name = "blue"
+    blue_theme = napari.utils.theme.get_theme("dark")
+    blue_theme.label = "blue"
     blue_theme.background = "#4169e1"  # my favourite shade of blue
-    napari.utils.theme.register_theme("blue", blue_theme)
+    napari.utils.theme.register_theme(
+        "blue", blue_theme, source="napari-mpl-tests"
+    )
 
 
 def test_theme_background_check(make_napari_viewer):
@@ -120,23 +120,3 @@ def test_no_theme_side_effects(make_napari_viewer):
     unrelated_figure.tight_layout()
 
     return unrelated_figure
-
-
-@pytest.mark.mpl_image_compare
-def test_custom_theme(make_napari_viewer, theme_path, brain_data):
-    viewer = make_napari_viewer()
-    viewer.theme = "dark"
-
-    widget = ScatterWidget(viewer)
-    widget.mpl_style_sheet_path = theme_path
-
-    viewer.add_image(brain_data[0], **brain_data[1], name="brain")
-    viewer.add_image(
-        brain_data[0] * -1, **brain_data[1], name="brain_reversed"
-    )
-
-    viewer.layers.selection.clear()
-    viewer.layers.selection.add(viewer.layers[0])
-    viewer.layers.selection.add(viewer.layers[1])
-
-    return deepcopy(widget.figure)
diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py
index a8792d41..e966cc26 100644
--- a/src/napari_matplotlib/tests/test_util.py
+++ b/src/napari_matplotlib/tests/test_util.py
@@ -26,7 +26,7 @@ def test_interval():
     assert 10 not in interval
 
     with pytest.raises(ValueError, match="must be an integer"):
-        "string" in interval  # type: ignore
+        assert "string" in interval  # type: ignore[operator]
 
     with pytest.raises(ValueError, match="must be <= upper_bound"):
         Interval(5, 3)
@@ -69,7 +69,10 @@ def test_fallback_if_missing_dimensions(mocker):
     test_css = " Flobble { background-color: rgb(0, 97, 163); } "
     mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css
     with pytest.warns(RuntimeWarning, match="Unable to find DimensionToken"):
-        assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(1, 2)
+        with pytest.warns(RuntimeWarning, match="Unable to find Flobble"):
+            assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(
+                1, 2
+            )
 
 
 def test_fallback_if_prelude_not_in_css():
diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py
index 2aa15ddd..8d4150c3 100644
--- a/src/napari_matplotlib/util.py
+++ b/src/napari_matplotlib/util.py
@@ -1,8 +1,8 @@
-from typing import List, Optional, Tuple, Union
 from warnings import warn
 
 import napari.qt
 import tinycss2
+from napari.utils.theme import Theme
 from qtpy.QtCore import QSize
 
 
@@ -11,7 +11,7 @@ class Interval:
     An integer interval.
     """
 
-    def __init__(self, lower_bound: Optional[int], upper_bound: Optional[int]):
+    def __init__(self, lower_bound: int | None, upper_bound: int | None):
         """
         Parameters
         ----------
@@ -47,7 +47,7 @@ def __contains__(self, val: int) -> bool:
         return True
 
     @property
-    def _helper_text(self) -> Optional[str]:
+    def _helper_text(self) -> str | None:
         """
         Helper text for widgets.
         """
@@ -76,7 +76,7 @@ def _helper_text(self) -> Optional[str]:
         return helper_text
 
 
-def _has_id(nodes: List[tinycss2.ast.Node], id_name: str) -> bool:
+def _has_id(nodes: list[tinycss2.ast.Node], id_name: str) -> bool:
     """
     Is `id_name` in IdentTokens in the list of CSS `nodes`?
     """
@@ -85,9 +85,7 @@ def _has_id(nodes: List[tinycss2.ast.Node], id_name: str) -> bool:
     )
 
 
-def _get_dimension(
-    nodes: List[tinycss2.ast.Node], id_name: str
-) -> Union[int, None]:
+def _get_dimension(nodes: list[tinycss2.ast.Node], id_name: str) -> int | None:
     """
     Get the value of the DimensionToken for the IdentToken `id_name`.
 
@@ -96,19 +94,23 @@ def _get_dimension(
         None if no IdentToken is found.
     """
     cleaned_nodes = [node for node in nodes if node.type != "whitespace"]
-    for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4):
+    for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4, strict=False):
         if (
             name.type == "ident"
             and value.type == "dimension"
             and name.value == id_name
         ):
             return value.int_value
-    warn(f"Unable to find DimensionToken for {id_name}", RuntimeWarning)
+    warn(
+        f"Unable to find DimensionToken for {id_name}",
+        RuntimeWarning,
+        stacklevel=1,
+    )
     return None
 
 
 def from_napari_css_get_size_of(
-    qt_element_name: str, fallback: Tuple[int, int]
+    qt_element_name: str, fallback: tuple[int, int]
 ) -> QSize:
     """
     Get the size of `qt_element_name` from napari's current stylesheet.
@@ -136,5 +138,52 @@ def from_napari_css_get_size_of(
         f"Unable to find {qt_element_name} or unable to find its size in "
         f"the current Napari stylesheet, falling back to {fallback}",
         RuntimeWarning,
+        stacklevel=1,
     )
     return QSize(*fallback)
+
+
+def style_sheet_from_theme(theme: Theme) -> dict[str, str]:
+    """Translate napari theme to a matplotlib style dictionary.
+
+    Parameters
+    ----------
+    theme : napari.utils.theme.Theme
+        Napari theme object representing the theme of the current viewer.
+
+    Returns
+    -------
+    Dict[str, str]
+        Matplotlib compatible style dictionary.
+    """
+    return {
+        "axes.edgecolor": theme.secondary.as_hex(),
+        # BUG: could be the same as napari canvas, but facecolors do not get
+        #     updated upon redraw for what ever reason
+        #'axes.facecolor':theme.canvas.as_hex(),
+        "axes.facecolor": "none",
+        "axes.labelcolor": theme.text.as_hex(),
+        "boxplot.boxprops.color": theme.text.as_hex(),
+        "boxplot.capprops.color": theme.text.as_hex(),
+        "boxplot.flierprops.markeredgecolor": theme.text.as_hex(),
+        "boxplot.whiskerprops.color": theme.text.as_hex(),
+        "figure.edgecolor": theme.secondary.as_hex(),
+        # BUG: should be the same as napari background, but facecolors do not get
+        #     updated upon redraw for what ever reason
+        #'figure.facecolor':theme.background.as_hex(),
+        "figure.facecolor": "none",
+        "grid.color": theme.foreground.as_hex(),
+        # COMMENT: the hard coded colors are to match the previous behaviour
+        #         alternativly we could use the theme to style the legend as well
+        #'legend.edgecolor':theme.secondary.as_hex(),
+        "legend.edgecolor": "black",
+        #'legend.facecolor':theme.background.as_hex(),
+        "legend.facecolor": "white",
+        #'legend.labelcolor':theme.text.as_hex()
+        "legend.labelcolor": "black",
+        "text.color": theme.text.as_hex(),
+        "xtick.color": theme.secondary.as_hex(),
+        "xtick.labelcolor": theme.text.as_hex(),
+        "ytick.color": theme.secondary.as_hex(),
+        "ytick.labelcolor": theme.text.as_hex(),
+    }
diff --git a/tox.ini b/tox.ini
index 7f78e2d5..f4aed6a8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,22 +1,14 @@
-# For more information about tox, see https://tox.readthedocs.io/en/latest/
 [tox]
-envlist = py{38,39,310}
+envlist = py{310,311,312}
 isolated_build = true
 
 [gh-actions]
 python =
-    3.8: py38
-    3.9: py39
     3.10: py310
+    3.11: py311
+    3.12: py312
 
 [testenv]
 extras = testing
-allowlist_externals =
-    cp
-    ls
-    tree
 commands =
-    cp -R {toxinidir}/src/napari_matplotlib/tests/baseline {envdir}/baseline
-    ls {toxinidir}/src/napari_matplotlib/tests/baseline
-    python -c 'from skimage import data; data.brain()'
     python -m pytest --mpl --mpl-generate-summary=html --mpl-results-path={toxinidir}/reports -v --color=yes --cov=napari_matplotlib --cov-report=xml
