diff --git a/ci/deps_pinned.txt b/ci/deps_pinned.txt
index d1a72958e2..0bb439f77d 100644
--- a/ci/deps_pinned.txt
+++ b/ci/deps_pinned.txt
@@ -1,5 +1,5 @@
-numpy=1.13
+numpy=1.15
 scipy=1.0
 pandas=0.23
-matplotlib=2.1
+matplotlib=2.2
 statsmodels=0.8
diff --git a/doc/Makefile b/doc/Makefile
index 1dfec5de5c..8ed9484cd5 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -44,13 +44,11 @@ clean:
 	-rm -rf $(BUILDDIR)/*
 	-rm -rf examples/*
 	-rm -rf example_thumbs/*
-	-rm -rf tutorial/*_files/
-	-rm -rf tutorial/*.rst
-	-rm -rf docstrings/*_files/
-	-rm -rf docstrings/*.rst
 	-rm -rf generated/*
 	-rm -rf introduction_files/*
 	-rm introduction.rst
+	-make -C docstrings clean
+	-make -C tutorial clean

 .PHONY: tutorials
 tutorials:
diff --git a/doc/api.rst b/doc/api.rst
index 520d0edafb..81cde3ea95 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -44,6 +44,7 @@ Distribution plots
     :toctree: generated/

     distplot
+    histplot
     kdeplot
     rugplot

diff --git a/doc/docstrings/Makefile b/doc/docstrings/Makefile
index 57045bfc0e..dd04013de1 100644
--- a/doc/docstrings/Makefile
+++ b/doc/docstrings/Makefile
@@ -8,3 +8,8 @@ docstrings: ${rst_files}
 	@if [ -f ../generated/seaborn.$*.rst ]; then \
 	    touch ../generated/seaborn.$*.rst; \
 	 fi
+
+clean:
+	rm -rf *.rst
+	rm -rf *_files/
+	rm -rf .ipynb_checkpoints/
diff --git a/doc/docstrings/histplot.ipynb b/doc/docstrings/histplot.ipynb
new file mode 100644
index 0000000000..2efde821ef
--- /dev/null
+++ b/doc/docstrings/histplot.ipynb
@@ -0,0 +1,471 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Plot a univariate distribution along the x axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import seaborn as sns; sns.set(style=\"white\")\n",
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Flip the plot by assigning the data variable to the y axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, y=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Check how well the histogram represents the data by specifying a different bin width:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", binwidth=3)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also define the total number of bins to use:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", bins=30)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Add a kernel density estimate to smooth the histogram, providing complementary information about the shape of the distribution:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", kde=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If neither `x` nor `y` is assigned, the dataset is treated as wide-form, and a histogram is drawn for each numeric column:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "iris = sns.load_dataset(\"iris\")\n",
+    "sns.histplot(data=iris)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also draw multiple histograms from a long-form dataset with hue mapping:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default approach to plotting multiple distributions is to \"layer\" them, but you can also \"stack\" them:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Overlapping bars can be hard to visually resolve. A different approach would be to draw a step function:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"step\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can move even farther away from bars by drawing a polygon with vertices in the center of each bin. This may make it easier to see the shape of the distribution, but use with caution: it will be less obvious to your audience that they are looking at a histogram:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"poly\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To compare the distribution of subsets that differ substantially in size, use indepdendent density normalization:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    penguins, x=\"culmen_length_mm\", hue=\"island\", element=\"step\",\n",
+    "    stat=\"density\", common_norm=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's also possible to normalize so that each bar's height shows a probability, which make more sense for discrete variables:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tips = sns.load_dataset(\"tips\")\n",
+    "sns.histplot(data=tips, x=\"size\", stat=\"probability\", discrete=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can even draw a histogram over categorical variables (although this is an experimental feature):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=tips, x=\"day\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "When using a ``hue`` semantic with discrete data, it can make sense to \"dodge\" the levels:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=tips, x=\"day\", hue=\"sex\", multiple=\"dodge\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For heavily skewed distributions, it's better to define the bins in log space. Compare:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "planets = sns.load_dataset(\"planets\")\n",
+    "sns.histplot(data=planets, x=\"distance\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To the log-scale version:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "There are also a number of options for how the histogram appears. You can show unfilled bars:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True, fill=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Or an unfilled step function:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True, element=\"step\", fill=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Step functions, esepcially when unfilled, make it easy to compare cumulative histograms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    data=planets, x=\"distance\", hue=\"method\",\n",
+    "    hue_order=[\"Radial Velocity\", \"Transit\"],\n",
+    "    log_scale=True, element=\"step\", fill=False,\n",
+    "    cumulative=True, stat=\"density\", common_norm=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "When both ``x`` and ``y`` are assigned, a bivariate histogram is computed and shown as a heatmap:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"culmen_depth_mm\", y=\"body_mass_g\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's possible to assign a ``hue`` variable too, although this will not work well if data from the different levels have substantial overlap:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"culmen_depth_mm\", y=\"body_mass_g\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Multiple color maps can make sense when one of the variables is discrete:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    penguins, x=\"culmen_depth_mm\", y=\"species\", hue=\"species\", legend=False\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The bivariate histogram accepts all of the same options for computation as its univariate counterpart, using tuples to parametrize ``x`` and ``y`` independently:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default behavior makes cells with no observations transparent, although this can be disabled: "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    thresh=None,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's also possible to set the threshold and colormap saturation point in terms of the proportion of cumulative counts:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    pthresh=.05, pmax=.9,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To annotate the colormap, add a colorbar:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    cbar=True, cbar_kws=dict(shrink=.75),\n",
+    ")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-refactor (py38)",
+   "language": "python",
+   "name": "seaborn-refactor"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/doc/releases/v0.11.0.txt b/doc/releases/v0.11.0.txt
index 0fde4318ec..9cdc3b7a12 100644
--- a/doc/releases/v0.11.0.txt
+++ b/doc/releases/v0.11.0.txt
@@ -9,38 +9,42 @@ v0.11.0 (Unreleased)
 Modernization of distribution functions
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

-Completely overhauled :func:`kdeplot` and :func:`rugplot`. Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+First, a new function, :func:`histplot` has been added. :func:`histplot` draws univariate or bivariate histograms with a number of features, including:

-The new functions now share a common API with the rest of seaborn, can show conditional distributions by mapping a third variable with a ``hue`` semantic, have been improved in numerous other ways. The `github pull request (GH2104) <https://github.com/mwaskom/seaborn/pull/2104>`_ has a longer explanation of the changes and the motivation behind them.
+- mapping multiple distributions with a ``hue`` semantic
+- normalization to show density, probability, or frequency statistics
+- flexible parameterization of bin size, including proper bins for discrete variables
+- adding a KDE fit to show a smoothed distribution over all bin statistics
+- experimental support for histograms over categorical and datetime variables. GH2125
+
+Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled. Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+
+The overhauled functions now share a common API with the rest of seaborn, they can show conditional distributions by mapping a third variable with a ``hue`` semantic, and have been improved in numerous other ways. The `github pull request (GH2104) <https://github.com/mwaskom/seaborn/pull/2104>`_ has a longer explanation of the changes and the motivation behind them.

 This is a necessarily API-breaking change. The parameter names for the positional variables
 are now ``x`` and ``y``, and the old names have been deprecated. Efforts were made to handle and warn when using the deprecated API, but it is strongly suggested to check your plots carefully.

-Additionally, the optional statsmodels-based computation of the KDE has been removed. Because there were some inconsistencies between the way different parameters (specifically, ``bw``, ``clip``, and ``cut``) were implemented by each backend, this may cause plots to look different after an upgrade. Support for using non-Gaussian kernels, which was available only in the statsmodels backend, has been removed. Support for drawing a cumulative distribution function, which was not previously available with the scipy backend, has been added, including for bivariate densities.
+Additionally, the optional statsmodels-based computation of the KDE has been removed. Because there were some inconsistencies between the way different parameters (specifically, ``bw``, ``clip``, and ``cut``) were implemented by each backend, this may cause plots to look different after an upgrade when non-default values were set for these parameters. Support for using non-Gaussian kernels, which was available only in the statsmodels backend, has been removed. Support for drawing a cumulative distribution function, which was not previously available with the scipy backend, has been added, including for bivariate densities.

 Other new features include:

-- Several options for representing multiple densities from subsets of data defined by semantic variables (with the new ``multiple`` and ``common_norm`` parameters)
-- Weighted KDE (using the new ``weights=`` parameter)
-- Better control over the smoothing bandwidth (using the new ``bw_adjust`` parameter)
-- More meaningful and configurable selection of the contours that represent a bivariate density (using the new ``thresh`` and ``levels`` parameters)
-- Log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
-- "Bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)
+- several options for representing multiple densities from subsets of data defined by semantic variables (with the new ``multiple`` and ``common_norm`` parameters)
+- weighted KDE (using the new ``weights=`` parameter)
+- better control over the smoothing bandwidth (using the new ``bw_adjust`` parameter)
+- more meaningful and configurable selection of the contours that represent a bivariate density (using the new ``thresh`` and ``levels`` parameters)
+- log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
+- "bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)

 GH2060, GH2104

 Other
 ~~~~~

-- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_. GH2075
-
-- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. GH2086, GH2118, GH803
-
-- The visual appearance of :func:`boxenplot` now more resembles that of :func:`boxplot`.
+- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.  GH2084

-- Fixed a bug where elements of :func:`boxenplot` were sometimes not entirely visible. GH2085
+- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_. GH2075

-- Following its deprecation, `lvplot` has finally been removed from the codebase.
+- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. GH2086

 - Added a ``tight_layout`` method to :class:`FacetGrid` and :class:`PairGrid`, which runs the :func:`matplotlib.pyplot.tight_layout` algorithm without interference from the external legend. GH2073

@@ -48,7 +52,7 @@ Other

 - Adapted to a change in matplotlib that prevented passing vectors of literal values to ``c`` and ``s`` in :func:`scatterplot`.  GH2079

-- Added an explicit warning in :func:`swarmplot` when more than 2% of the points are overlap in the "gutters" of the swarm. GH2045
+- Added an explicit warning in :func:`swarmplot` when more than 2% of the points overlap in the "gutters" of the swarm. GH2045

 - Added the ``axes_dict`` attribute to :class:`FacetGrid` for named access to the component axes.  GH2046

@@ -58,8 +62,6 @@ Other

 - Fixed a bug where :func:`lineplot` did not pass the ``linestyle`` parameter down to matplotlib. GH2095

-- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.  GH2084
-
 - Improved the error messages produced when categorical plots process the orientation parameter.

 - Deprecated the ``axlabel`` function; use ``ax.set(xlabel=, ylabel=)`` instead.
diff --git a/doc/tutorial/Makefile b/doc/tutorial/Makefile
index c8ca64d97d..fda6b816ee 100644
--- a/doc/tutorial/Makefile
+++ b/doc/tutorial/Makefile
@@ -4,3 +4,8 @@ tutorial: ${rst_files}

 %.rst: %.ipynb
 	../tools/nb_to_doc.py $*.ipynb
+
+clean:
+	rm -rf *.rst
+	rm -rf *_files/
+	rm -rf .ipynb_checkpoints/
diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst
index 5be2ab30ff..08eb9ce8ec 100644
--- a/doc/whatsnew.rst
+++ b/doc/whatsnew.rst
@@ -11,6 +11,8 @@ This page contains information about what has changed in each new version of ``s

    <div class="col-md-9">

+.. include:: releases/v0.11.0.txt
+
 .. include:: releases/v0.10.1.txt

 .. include:: releases/v0.10.0.txt
diff --git a/examples/multi_bivariate_plot.py b/examples/multi_bivariate_plot.py
new file mode 100644
index 0000000000..68a26f3bfc
--- /dev/null
+++ b/examples/multi_bivariate_plot.py
@@ -0,0 +1,23 @@
+"""
+Bivariate plot with multiple elements
+=====================================
+
+
+"""
+import numpy as np
+import seaborn as sns
+import matplotlib.pyplot as plt
+sns.set(style="dark")
+
+# Simulate data from a bivariate Gaussian
+n = 10000
+mean = [0, 0]
+cov = [(2, .4), (.4, .2)]
+rng = np.random.RandomState(0)
+x, y = rng.multivariate_normal(mean, cov, n).T
+
+# Draw a combo histogram and scatterplot with density contours
+f, ax = plt.subplots(figsize=(6, 6))
+sns.scatterplot(x=x, y=y, s=5, color=".2")
+sns.histplot(x=x, y=y, bins=50, pthresh=.1, color="seagreen")
+sns.kdeplot(x=x, y=y, levels=5, color="w", linewidths=1)
diff --git a/examples/multiple_regression.py b/examples/multiple_regression.py
index 97a595967a..0cb7054b0e 100644
--- a/examples/multiple_regression.py
+++ b/examples/multiple_regression.py
@@ -7,12 +7,15 @@
 import seaborn as sns
 sns.set()

-# Load the iris dataset
-iris = sns.load_dataset("iris")
+# Load the penguins dataset
+penguins = sns.load_dataset("penguins")

 # Plot sepal width as a function of sepal_length across days
-g = sns.lmplot(x="sepal_length", y="sepal_width", hue="species",
-               height=5, data=iris)
+g = sns.lmplot(
+    data=penguins,
+    x="culmen_length_mm", y="culmen_depth_mm", hue="species",
+    height=5
+)

 # Use more informative axis labels than are provided by default
-g.set_axis_labels("Sepal length (mm)", "Sepal width (mm)")
+g.set_axis_labels("Snoot length (mm)", "Snoot depth (mm)")
diff --git a/examples/smooth_bivariate_kde.py b/examples/smooth_bivariate_kde.py
index ca1fcd52a0..d6263535d1 100644
--- a/examples/smooth_bivariate_kde.py
+++ b/examples/smooth_bivariate_kde.py
@@ -1,5 +1,6 @@
 """
 Smooth bivariate kernel density
+===============================

 _thumb: .5, .45
 """
diff --git a/examples/stacked_histogram.py b/examples/stacked_histogram.py
new file mode 100644
index 0000000000..0c514f3b60
--- /dev/null
+++ b/examples/stacked_histogram.py
@@ -0,0 +1,29 @@
+"""
+Stacked histogram on a log scale
+================================
+
+_thumb: .5, .45
+
+"""
+import seaborn as sns
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+
+sns.set(style="ticks")
+
+diamonds = sns.load_dataset("diamonds")
+
+f, ax = plt.subplots(figsize=(7, 5))
+sns.despine(f)
+
+sns.histplot(
+    diamonds,
+    x="price", hue="cut",
+    multiple="stack",
+    palette="Blues_r",
+    edgecolor=".3",
+    linewidth=.5,
+    log_scale=True,
+)
+ax.xaxis.set_major_formatter(mpl.ticker.ScalarFormatter())
+ax.set_xticks([500, 1000, 2000, 5000, 10000])
diff --git a/seaborn/_core.py b/seaborn/_core.py
index 566f423921..b737854119 100644
--- a/seaborn/_core.py
+++ b/seaborn/_core.py
@@ -849,7 +849,9 @@ def _assign_variables_longform(self, data=None, **kwargs):

         return plot_data, variables

-    def _semantic_subsets(self, grouping_semantics, reverse=False):
+    def _semantic_subsets(
+        self, grouping_semantics, reverse=False, from_comp_data=False,
+    ):
         """Generator for getting subsets of data defined by semantic variables.

         Parameters
@@ -858,6 +860,8 @@ def _semantic_subsets(self, grouping_semantics, reverse=False):
             Semantic variables that define the subsets of data.
         reverse : bool, optional
             If True, reverse the order of iteration.
+        from_comp_data : bool, optional
+            If True, use self.comp_data rather than self.plot_data

         Yields
         ------
@@ -875,9 +879,14 @@ def _semantic_subsets(self, grouping_semantics, reverse=False):
             var for var in grouping_semantics if var in self.variables
         ]

+        if from_comp_data:
+            data = self.comp_data
+        else:
+            data = self.plot_data
+
         if grouping_semantics:

-            grouped_data = self.plot_data.groupby(
+            grouped_data = data.groupby(
                 grouping_semantics, sort=False, as_index=False
             )

@@ -905,10 +914,89 @@ def _semantic_subsets(self, grouping_semantics, reverse=False):

         else:

-            yield {}, self.plot_data
+            yield {}, data

-    def _add_axis_labels(self, ax, default_x="", default_y=""):
+    @property
+    def comp_data(self):
+        """Dataframe with numeric x and y, after unit conversion and log scaling."""
+        if not hasattr(self, "ax"):
+            raise AttributeError("No Axes attached to plotter")
+
+        if not hasattr(self, "_comp_data"):
+
+            comp_data = self.plot_data.copy(deep=False)
+            for var in "xy":
+                axis = getattr(self.ax, f"{var}axis")
+                comp_var = axis.convert_units(self.plot_data[var])
+                if axis.get_scale() == "log":
+                    comp_var = np.log10(comp_var)
+                comp_data[var] = comp_var
+            self._comp_data = comp_data
+
+        return self._comp_data
+
+    def _attach(self, ax, allowed_types=None, log_scale=None):
+        """Associate the plotter with a matplotlib Axes and initialize its units.
+
+        Parameters
+        ----------
+        ax : :class:`matplotlib.axes.Axes`
+            Axes object that we will eventually plot onto.
+        allowed_types : str or list of str
+            If provided, raise when either the x or y variable does not have
+            one of the declared seaborn types.
+        log_scale : bool, number, or pair of bools or numbers
+            If not False, set the axes to use log scaling, with the given
+            base or defaulting to 10. If a tuple, interpreted as separate
+            arguments for the x and y axes.

+        """
+        if allowed_types is None:
+            # TODO should we define this default somewhere?
+            allowed_types = ["numeric", "datetime", "categorical"]
+        elif isinstance(allowed_types, str):
+            allowed_types = [allowed_types]
+
+        for var in set("xy").intersection(self.variables):
+
+            # Check types of x/y variables
+            var_type = self.var_types[var]
+            if var_type not in allowed_types:
+                err = (
+                    f"The {var} variable is {var_type}, but one of "
+                    f"{allowed_types} is required"
+                )
+                raise TypeError(err)
+
+            # Register with the matplotlib unit conversion machinery
+            # TODO do we want to warn or raise if mixing units?
+            axis = getattr(ax, f"{var}axis")
+            seed_data = self.plot_data[var]
+            if var_type == "categorical":
+                seed_data = categorical_order(seed_data)
+            axis.update_units(seed_data)
+
+        # Possibly log-scale one or both axes
+        if log_scale is not None:
+            # Allow single value or x, y tuple
+            try:
+                scalex, scaley = log_scale
+            except TypeError:
+                scalex = log_scale if "x" in self.variables else False
+                scaley = log_scale if "y" in self.variables else False
+
+            for axis, scale in zip("xy", (scalex, scaley)):
+                if scale:
+                    set_scale = getattr(ax, f"set_{axis}scale")
+                    if scale is True:
+                        set_scale("log")
+                    else:
+                        set_scale("log", **{f"base{axis}": scale})
+
+        self.ax = ax
+
+    def _add_axis_labels(self, ax, default_x="", default_y=""):
+        """Add axis labels from internal variable names if not already existing."""
         if not ax.get_xlabel():
             ax.set_xlabel(self.variables.get("x", default_x))
         if not ax.get_ylabel():
diff --git a/seaborn/_docstrings.py b/seaborn/_docstrings.py
index 52be7d6aa8..010dcdb8e5 100644
--- a/seaborn/_docstrings.py
+++ b/seaborn/_docstrings.py
@@ -42,7 +42,7 @@ def from_function_params(cls, func):
         for p in params:
             name = p.name
             type = p.type
-            desc = "    \n".join(p.desc)
+            desc = "\n    ".join(p.desc)
             comp_dict[name] = f"{name} : {type}\n    {desc}"

         return cls(comp_dict)
@@ -84,8 +84,8 @@ def from_function_params(cls, func):
     hue_norm="""
 hue_norm : tuple or :class:`matplotlib.colors.Normalize`
     Either a pair of values that set the normalization range in data units
-    for numeric ``hue`` mapping. Can also be an object that will map from data
-    units into a [0, 1] interval. Usage implies numeric mapping.
+    or an object that will map from data units into a [0, 1] interval. Usage
+    implies numeric mapping.
     """,
     color="""
 color : :mod:`matplotlib color <matplotlib.colors>`
@@ -111,6 +111,12 @@ def from_function_params(cls, func):
 _seealso_blurbs = dict(

     # Distribution plots
+    histplot="""
+histplot : Plot a histogram of binned counts with optional normalization or smoothing.
+    """,
+    kdeplot="""
+kdeplot : Plot univariate or bivariate distributions using kernel density estimation.
+    """,
     rugplot="""
 rugplot : Plot a tick at each observation value along the x and/or y axes.
     """,
diff --git a/seaborn/_statistics.py b/seaborn/_statistics.py
index 0f4cb4c86b..951795c100 100644
--- a/seaborn/_statistics.py
+++ b/seaborn/_statistics.py
@@ -1,8 +1,11 @@
 from distutils.version import LooseVersion
+from numbers import Number
 import numpy as np
 import scipy as sp
 from scipy import stats

+from .utils import _check_argument
+

 class KDE:
     """Univariate and bivariate kernel density estimator."""
@@ -158,3 +161,187 @@ def __call__(self, x1, x2=None, weights=None):
             return self._eval_univariate(x1, weights)
         else:
             return self._eval_bivariate(x1, x2, weights)
+
+
+class Histogram:
+    """Univariate and bivariate histogram estimator."""
+    def __init__(
+        self,
+        stat="count",
+        bins="auto",
+        binwidth=None,
+        binrange=None,
+        discrete=False,
+        cumulative=False,
+    ):
+        """Initialize the estimator with its parameters.
+
+        Parameters
+        ----------
+        stat : {"count", "frequency", "density", "probability"}
+            Aggregate statistic to compute in each bin.
+
+            - ``count`` shows the number of observations
+            - ``frequency`` shows the number of observations divided by the bin width
+            - ``density`` normalizes counts so that the area of the histogram is 1
+            - ``probability`` normalizes counts so that the sum of the bar heights is 1
+
+        bins : str, number, vector, or a pair of such values
+            Generic bin parameter that can be the name of a reference rule,
+            the number of bins, or the breaks of the bins.
+            Passed to :func:`numpy.histogram_bin_edges`.
+        binwidth : number or pair of numbers
+            Width of each bin, overrides ``bins`` but can be used with
+            ``binrange``.
+        binrange : pair of numbers or a pair of pairs
+            Lowest and highest value for bin edges; can be used either
+            with ``bins`` or ``binwidth``. Defaults to data extremes.
+        discrete : bool or pair of bools
+            If True, set ``binwidth`` and ``binrange`` such that bin
+            edges cover integer values in the dataset.
+        cumulative : bool
+            If True, return the cumulative statistic.
+
+        """
+        _check_argument("stat", ["count", "density", "probability", "frequency"], stat)
+
+        self.stat = stat
+        self.bins = bins
+        self.binwidth = binwidth
+        self.binrange = binrange
+        self.discrete = discrete
+        self.cumulative = cumulative
+
+        self.bin_edges = None
+
+    def _define_bin_edges(self, x, weights, bins, binwidth, binrange, discrete):
+        """Inner function that takes bin parameters as arguments."""
+        if binrange is None:
+            start, stop = x.min(), x.max()
+        else:
+            start, stop = binrange
+
+        if discrete:
+            bin_edges = np.arange(start - .5, stop + 1.5)
+        elif binwidth is not None:
+            step = binwidth
+            bin_edges = np.arange(start, stop + step, step)
+        else:
+            bin_edges = np.histogram_bin_edges(
+                x, bins, binrange, weights,
+            )
+        return bin_edges
+
+    def define_bin_edges(self, x1, x2=None, weights=None, cache=True):
+        """Given data, return the edges of the histogram bins."""
+        if x2 is None:
+
+            bin_edges = self._define_bin_edges(
+                x1, weights, self.bins, self.binwidth, self.binrange, self.discrete,
+            )
+
+        else:
+
+            bin_edges = []
+            for i, x in enumerate([x1, x2]):
+
+                # Resolve out whether bin parameters are shared
+                # or specific to each variable
+
+                bins = self.bins
+                if bins is None or isinstance(bins, (str, Number)):
+                    pass
+                elif isinstance(bins[i], str):
+                    bins = bins[i]
+                elif len(bins) == 2:
+                    bins = bins[i]
+
+                binwidth = self.binwidth
+                if binwidth is None:
+                    pass
+                elif not isinstance(binwidth, Number):
+                    binwidth = binwidth[i]
+
+                binrange = self.binrange
+                if binrange is None:
+                    pass
+                elif not isinstance(binrange[0], Number):
+                    binrange = binrange[i]
+
+                discrete = self.discrete
+                if not isinstance(discrete, bool):
+                    discrete = discrete[i]
+
+                # Define the bins for this variable
+
+                bin_edges.append(self._define_bin_edges(
+                    x, weights, bins, binwidth, binrange, discrete,
+                ))
+
+            bin_edges = tuple(bin_edges)
+
+        if cache:
+            self.bin_edges = bin_edges
+
+        return bin_edges
+
+    def _eval_bivariate(self, x1, x2, weights):
+        """Inner function for histogram of two variables."""
+        bin_edges = self.bin_edges
+        if bin_edges is None:
+            bin_edges = self.define_bin_edges(x1, x2, cache=False)
+
+        density = self.stat == "density"
+
+        hist, _, _ = np.histogram2d(
+            x1, x2, bin_edges, weights=weights, density=density
+        )
+
+        area = np.outer(
+            np.diff(bin_edges[0]),
+            np.diff(bin_edges[1]),
+        )
+
+        if self.stat == "probability":
+            hist = hist.astype(float) / hist.sum()
+        elif self.stat == "frequency":
+            hist = hist.astype(float) / area
+
+        if self.cumulative:
+            if self.stat in ["density", "frequency"]:
+                hist = (hist * area).cumsum(axis=0).cumsum(axis=1)
+            else:
+                hist = hist.cumsum(axis=0).cumsum(axis=1)
+
+        return hist, bin_edges
+
+    def _eval_univariate(self, x, weights):
+        """Inner function for histogram of one variable."""
+        bin_edges = self.bin_edges
+        if bin_edges is None:
+            bin_edges = self.define_bin_edges(x, weights=weights, cache=False)
+
+        density = self.stat == "density"
+        hist, _ = np.histogram(
+            x, bin_edges, weights=weights, density=density,
+        )
+
+        if self.stat == "probability":
+            hist = hist.astype(float) / hist.sum()
+        elif self.stat == "frequency":
+            hist = hist.astype(float) / np.diff(bin_edges)
+
+        if self.cumulative:
+            if self.stat in ["density", "frequency"]:
+                hist = (hist * np.diff(bin_edges)).cumsum()
+            else:
+                hist = hist.cumsum()
+
+        return hist, bin_edges
+
+    def __call__(self, x1, x2=None, weights=None):
+        """Count the occurrances in each bin, maybe normalize."""
+        if x2 is None:
+            return self._eval_univariate(x1, weights)
+        else:
+            return self._eval_bivariate(x1, x2, weights)
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index 18e30360d0..9b2fc2e0c4 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -5,14 +5,13 @@

 import numpy as np
 import pandas as pd
-from scipy import stats
 import matplotlib as mpl
 import matplotlib.pyplot as plt

 from ._core import variable_type, categorical_order
 from . import utils
 from .palettes import color_palette, blend_palette
-from .distributions import distplot, kdeplot, _freedman_diaconis_bins
+from .distributions import histplot, kdeplot, _freedman_diaconis_bins
 from ._decorators import _deprecate_positional_args

@@ -1672,7 +1671,7 @@ def __init__(
             :context: close-figs

             >>> g = sns.JointGrid(x="total_bill", y="tip", data=tips)
-            >>> g = g.plot(sns.regplot, sns.distplot)
+            >>> g = g.plot(sns.regplot, sns.histplot)

         Draw the join and marginal plots separately, which allows finer-level
         control other parameters:
@@ -1683,7 +1682,7 @@ def __init__(
             >>> import matplotlib.pyplot as plt
             >>> g = sns.JointGrid(x="total_bill", y="tip", data=tips)
             >>> g = g.plot_joint(sns.scatterplot, color=".5")
-            >>> g = g.plot_marginals(sns.distplot, kde=False, color=".5")
+            >>> g = g.plot_marginals(sns.histplot, kde=True, color=".5")

         Draw the two marginal plots separately:

@@ -1693,11 +1692,10 @@ def __init__(
             >>> import numpy as np
             >>> g = sns.JointGrid(x="total_bill", y="tip", data=tips)
             >>> g = g.plot_joint(sns.scatterplot, color="m")
-            >>> _ = g.ax_marg_x.hist(tips["total_bill"], color="b", alpha=.6,
-            ...                      bins=np.arange(0, 60, 5))
-            >>> _ = g.ax_marg_y.hist(tips["tip"], color="r", alpha=.6,
-            ...                      orientation="horizontal",
-            ...                      bins=np.arange(0, 12, 1))
+            >>> _ = sns.histplot(x=tips["total_bill"], color="b", binwidth=5,
+            ...                  ax=g.ax_marg_x)
+            >>> _ = sns.histplot(y=tips["tip"], color="r", binwidth=1,
+            ...                  ax=g.ax_marg_y)

         Remove the space between the joint and marginal axes:

@@ -1803,6 +1801,9 @@ def __init__(
         utils.despine(f)
         utils.despine(ax=ax_marg_x, left=True)
         utils.despine(ax=ax_marg_y, bottom=True)
+        for axes in [ax_marg_x, ax_marg_y]:
+            for axis in [axes.xaxis, axes.yaxis]:
+                axis.label.set_visible(False)
         f.tight_layout()
         f.subplots_adjust(hspace=space, wspace=space)

@@ -1873,19 +1874,17 @@ def plot_marginals(self, func, **kwargs):
             Returns `self`.

         """
-        kwargs["vertical"] = False
         plt.sca(self.ax_marg_x)
         if str(func.__module__).startswith("seaborn"):
             func(x=self.x, **kwargs)
         else:
-            func(self.x, **kwargs)
+            func(self.x, vertical=False, **kwargs)

-        kwargs["vertical"] = True
         plt.sca(self.ax_marg_y)
         if str(func.__module__).startswith("seaborn"):
-            func(x=self.y, **kwargs)
+            func(y=self.y, **kwargs)
         else:
-            func(self.y, **kwargs)
+            func(self.y, vertical=True, **kwargs)

         self.ax_marg_x.yaxis.get_label().set_visible(False)
         self.ax_marg_y.xaxis.get_label().set_visible(False)
@@ -2347,7 +2346,7 @@ def jointplot(
         :context: close-figs

         >>> g = sns.jointplot(x="petal_length", y="sepal_length", data=iris,
-        ...                   marginal_kws=dict(bins=15, rug=True),
+        ...                   marginal_kws=dict(bins=15),
         ...                   annot_kws=dict(stat="r"),
         ...                   s=40, edgecolor="w", linewidth=1)

@@ -2388,7 +2387,7 @@ def jointplot(

         marginal_kws.setdefault("kde", False)
         marginal_kws.setdefault("color", color)
-        grid.plot_marginals(distplot, **marginal_kws)
+        grid.plot_marginals(histplot, **marginal_kws)

     elif kind.startswith("hex"):

@@ -2402,7 +2401,7 @@ def jointplot(

         marginal_kws.setdefault("kde", False)
         marginal_kws.setdefault("color", color)
-        grid.plot_marginals(distplot, **marginal_kws)
+        grid.plot_marginals(histplot, **marginal_kws)

     elif kind.startswith("kde"):

@@ -2419,7 +2418,8 @@ def jointplot(
         from .regression import regplot

         marginal_kws.setdefault("color", color)
-        grid.plot_marginals(distplot, **marginal_kws)
+        marginal_kws.setdefault("kde", True)
+        grid.plot_marginals(histplot, **marginal_kws)

         joint_kws.setdefault("color", color)
         grid.plot_joint(regplot, **joint_kws)
@@ -2433,10 +2433,8 @@ def jointplot(

         x, y = grid.ax_joint.collections[0].get_offsets().T
         marginal_kws.setdefault("color", color)
-        marginal_kws.setdefault("kde", False)
-        distplot(x=x, ax=grid.ax_marg_x, **marginal_kws)
-        distplot(x=y, vertical=True, fit=stats.norm, ax=grid.ax_marg_y,
-                 **marginal_kws)
+        histplot(x=x, ax=grid.ax_marg_x, kde=False, **marginal_kws)
+        histplot(y=y, ax=grid.ax_marg_y, kde=True, **marginal_kws)
         stat_func = None
     else:
         msg = "kind must be either 'scatter', 'reg', 'resid', 'kde', or 'hex'"
diff --git a/seaborn/distributions.py b/seaborn/distributions.py
index 6d68a58b23..13cfdca290 100644
--- a/seaborn/distributions.py
+++ b/seaborn/distributions.py
@@ -17,9 +17,15 @@
 )
 from ._statistics import (
     KDE,
+    Histogram,
 )
-from .utils import _kde_support, _normalize_kwargs, remove_na
-from .palettes import light_palette
+from .utils import (
+    remove_na,
+    _kde_support,
+    _normalize_kwargs,
+    _check_argument,
+)
+from .external import husl
 from ._decorators import _deprecate_positional_args
 from ._docstrings import (
     DocstringComponents,
@@ -27,18 +33,59 @@
 )

-__all__ = ["distplot", "kdeplot", "rugplot"]
+__all__ = ["distplot", "histplot", "kdeplot", "rugplot"]
+
+# ==================================================================================== #
+# Module documentation
+# ==================================================================================== #

+_dist_params = dict(
+
+    multiple="""
+multiple : {{"layer", "stack", "fill"}}
+    Method for drawing multiple elements when semantic mapping creates subsets.
+    Only relevant with univariate data.
+    """,
+    log_scale="""
+log_scale : bool or number, or pair of bools or numbers
+    Set a log scale on the data axis (or axes, with bivariate data) with the
+    given base (default 10), and evaluate the KDE in log space.
+    """,
+    legend="""
+legend : bool
+    If False, suppress the legend for semantic variables.
+    """,
+    cbar="""
+cbar : bool
+    If True, add a colorbar to annotate the color mapping in a bivariate plot.
+    Note: Does not currently support plots with a ``hue`` variable well.
+    """,
+    cbar_ax="""
+cbar_ax : :class:`matplotlib.axes.Axes`
+    Pre-existing axes for the colorbar.
+    """,
+    cbar_kws="""
+cbar_kws : dict
+    Additional parameters passed to :meth:`matplotlib.figure.Figure.colorbar`.
+    """,
+)

 _param_docs = DocstringComponents.from_nested_components(
     core=_core_docs["params"],
+    dist=DocstringComponents(_dist_params),
     kde=DocstringComponents.from_function_params(KDE.__init__),
+    hist=DocstringComponents.from_function_params(Histogram.__init__),
 )

+# ==================================================================================== #
+# Internal API
+# ==================================================================================== #
+
+
 class _DistributionPlotter(VectorPlotter):

-    semantics = "x", "y", "hue"
+    semantics = "x", "y", "hue", "weights"

     wide_structure = {"x": "values", "hue": "columns"}
     flat_structure = {"x": "values"}
@@ -51,115 +98,171 @@ def __init__(

         super().__init__(data=data, variables=variables)

+    @property
+    def univariate(self):
+        """Return True if only x or y are used."""
+        # TODO this could go down to core, but putting it here now.
+        # We'd want to be conceptually clear that univariate only applies
+        # to x/y and not to other semantics, which can exist.
+        # We haven't settled on a good conceptual name for x/y.
+        return bool({"x", "y"} - set(self.variables))
+
+    @property
+    def data_variable(self):
+        """Return the variable with data for univariate plots."""
+        # TODO This could also be in core, but it should have a better name.
+        if not self.univariate:
+            raise AttributeError("This is not a univariate plot")
+        return {"x", "y"}.intersection(self.variables).pop()
+
+    @property
+    def has_xy_data(self):
+        """Return True at least one of x or y is defined."""
+        # TODO see above points about where this should go
+        return bool({"x", "y"} & set(self.variables))
+
     def _add_legend(
-        self, ax, artist, fill, multiple, alpha, artist_kws, legend_kws
+        self,
+        ax, artist, fill, element, multiple, alpha, artist_kws, legend_kws,
     ):
-
+        """Add artists that reflect semantic mappings and put then in a legend."""
+        # TODO note that this doesn't handle numeric mappngs the way relational plots do
         handles = []
         labels = []
         for level in self._hue_map.levels:
             color = self._hue_map(level)
             handles.append(artist(
-                **self._artist_kws(artist_kws, fill, multiple, color, alpha)
+                **self._artist_kws(
+                    artist_kws, fill, element, multiple, color, alpha
+                )
             ))
             labels.append(level)

         ax.legend(handles, labels, title=self.variables["hue"], **legend_kws)

-    def _artist_kws(self, kws, fill, multiple, color, alpha):
+    def _artist_kws(self, kws, fill, element, multiple, color, alpha):
         """Handle differences between artists in filled/unfilled plots."""
         kws = kws.copy()
         if fill:
             kws.setdefault("facecolor", to_rgba(color, alpha))
-            if multiple == "layer":
-                kws.setdefault("edgecolor", to_rgba(color, 1))
-            else:
+            if multiple in ["stack", "fill"] or element == "bars":
                 kws.setdefault("edgecolor", mpl.rcParams["patch.edgecolor"])
+            else:
+                kws.setdefault("edgecolor", to_rgba(color, 1))
+        elif element == "bars":
+            kws["facecolor"] = "none"
+            kws["edgecolor"] = to_rgba(color, 1)
         else:
             kws["color"] = color
         return kws

+    def _quantile_to_level(self, data, quantile):
+        """Return data levels corresponding to quantile cuts of mass."""
+        isoprop = np.asarray(quantile)
+        values = np.ravel(data)
+        sorted_values = np.sort(values)[::-1]
+        normalized_values = np.cumsum(sorted_values) / values.sum()
+        idx = np.searchsorted(normalized_values, 1 - isoprop)
+        levels = np.take(sorted_values, idx, mode="clip")
+        return levels

-class _HistPlotter(_DistributionPlotter):
+    def _cmap_from_color(self, color):
+        """Return a sequential colormap given a color seed."""
+        # Like so much else here, this is broadly useful, but keeping it
+        # in this class to signify that I haven't thought overly hard about it...
+        r, g, b, _ = to_rgba(color)
+        h, s, _ = husl.rgb_to_husl(r, g, b)
+        xx = np.linspace(-1, 1, 256)
+        ramp = np.zeros((256, 3))
+        ramp[:, 0] = h
+        ramp[:, 1] = s * np.cos(xx)
+        ramp[:, 2] = np.linspace(35, 80, 256)
+        colors = np.clip([husl.husl_to_rgb(*hsl) for hsl in ramp], 0, 1)
+        return mpl.colors.ListedColormap(colors)
+
+    def _resolve_multiple(
+        self,
+        curves,
+        multiple,
+    ):

-    pass
+        # Modify the density data structure to handle multiple densities
+        if multiple in ("stack", "fill"):

+            # Setting stack or fill means that the curves share a
+            # support grid / set of bin edges, so we can make a dataframe
+            # Reverse the column order to plot from top to bottom
+            curves = pd.DataFrame(curves).iloc[:, ::-1]
+            norm_constant = curves.sum(axis="columns")

-class _KDEPlotter(_DistributionPlotter):
+            # Take the cumulative sum to stack
+            curves = curves.cumsum(axis="columns")

-    # TODO we maybe need a different category for variables that do not
-    # map to semantics of the plot, like weights
-    semantics = _DistributionPlotter.semantics + ("weights",)
+            # Normalize by row sum to fill
+            if multiple == "fill":
+                curves = curves.div(norm_constant, axis="index")

-    def plot_univariate(
-        self,
-        multiple,
-        common_norm,
-        common_grid,
-        fill,
-        legend,
-        estimate_kws,
-        plot_kws,
-        ax,
-    ):
+            # Define where each segment starts
+            baselines = curves.shift(1, axis=1).fillna(0)

-        # Preprocess the matplotlib keyword dictionaries
-        if fill:
-            artist = mpl.collections.PolyCollection
         else:
-            artist = mpl.lines.Line2D
-        plot_kws = _normalize_kwargs(plot_kws, artist)

-        # Input checking
-        multiple_options = ["layer", "stack", "fill"]
-        if multiple not in multiple_options:
-            msg = (
-                f"multiple must be one of {multiple_options}, "
-                f"but {multiple} was passed."
-            )
-            raise ValueError(msg)
+            # All densities will start at 0
+            baselines = {k: np.zeros_like(v) for k, v in curves.items()}

-        # Control the interaction with autoscaling by defining sticky_edges
-        # i.e. we don't want autoscale margins below the density curve
-        sticky_density = (0, 1) if multiple == "fill" else (0, np.inf)
+        if multiple == "dodge":

-        # Identify the axis with the data values
-        data_variable = {"x", "y"}.intersection(self.variables).pop()
+            n = len(curves)
+            for i, key in enumerate(curves):

-        # Check for log scaling on the data axis
-        data_axis = getattr(ax, f"{data_variable}axis")
-        log_scale = data_axis.get_scale() == "log"
+                hist = curves[key].reset_index(name="heights")
+
+                hist["widths"] /= n
+                hist["edges"] += i * hist["widths"]
+
+                curves[key] = hist.set_index(["edges", "widths"])["heights"]
+
+        return curves, baselines
+
+    # -------------------------------------------------------------------------------- #
+    # Computation
+    # -------------------------------------------------------------------------------- #
+
+    def _compute_univariate_density(
+        self,
+        data_variable,
+        common_norm,
+        common_grid,
+        estimate_kws,
+        log_scale,
+    ):

         # Initialize the estimator object
         estimator = KDE(**estimate_kws)

+        cols = list(self.variables)
+        all_data = self.plot_data[cols].dropna()
+
         if "hue" in self.variables:

             # Access and clean the data
-            all_observations = remove_na(self.plot_data[data_variable])
-
-            # Always share the evaluation grid when stacking
-            if multiple in ("stack", "fill"):
-                common_grid = True
+            all_observations = self.comp_data[cols].dropna()

             # Define a single grid of support for the PDFs
             if common_grid:
-                if log_scale:
-                    all_observations = np.log10(all_observations)
-                estimator.define_support(all_observations)
+                estimator.define_support(all_observations[data_variable])

         else:

             common_norm = False

-        # We will do two loops through the semantic subsets
-        # The first is to estimate the density of observations in each subset
         densities = {}

-        for sub_vars, sub_data in self._semantic_subsets("hue"):
+        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):

             # Extract the data points from this sub set and remove nulls
-            observations = remove_na(sub_data[data_variable])
+            sub_data = sub_data[cols].dropna()
+            observations = sub_data[data_variable]

             observation_variance = observations.var()
             if not observation_variance or np.isnan(observation_variance):
@@ -173,10 +276,6 @@ def plot_univariate(
             else:
                 weights = None

-            # If data axis is log scaled, fit the KDE in logspace
-            if log_scale:
-                observations = np.log10(observations)
-
             # Estimate the density of observations at this level
             density, support = estimator(observations, weights=weights)

@@ -185,36 +284,577 @@ def plot_univariate(

             # Apply a scaling factor so that the integral over all subsets is 1
             if common_norm:
-                density *= len(sub_data) / len(self.plot_data)
+                density *= len(sub_data) / len(all_data)

             # Store the density for this level
             key = tuple(sub_vars.items())
             densities[key] = pd.Series(density, index=support)

-        # Modify the density data structure to handle multiple densities
-        if multiple in ("stack", "fill"):
+        return densities

-            # The densities share a support grid, so we can make a dataframe
-            densities = pd.DataFrame(densities).iloc[:, ::-1]
-            norm_constant = densities.sum(axis="columns")
+    # -------------------------------------------------------------------------------- #
+    # Plotting
+    # -------------------------------------------------------------------------------- #

-            # Take the cumulative sum to stack
-            densities = densities.cumsum(axis="columns")
+    def plot_univariate_histogram(
+        self,
+        multiple,
+        element,
+        fill,
+        common_norm,
+        common_bins,
+        shrink,
+        kde,
+        kde_kws,
+        color,
+        legend,
+        line_kws,
+        estimate_kws,
+        plot_kws,
+        ax,
+    ):

-            # Normalize by row sum to fill
-            if multiple == "fill":
-                densities = densities.div(norm_constant, axis="index")
+        # --  Input checking
+        _check_argument("multiple", ["layer", "stack", "fill", "dodge"], multiple)
+        _check_argument("element", ["bars", "step", "poly"], element)

-            # Define where each segment starts
-            baselines = densities.shift(1, axis=1).fillna(0)
+        if estimate_kws["discrete"] and element != "bars":
+            raise ValueError("`element` must be 'bars' when `discrete` is True")
+
+        auto_bins_with_weights = (
+            "weights" in self.variables
+            and estimate_kws["bins"] == "auto"
+            and estimate_kws["binwidth"] is None
+            and not estimate_kws["discrete"]
+        )
+        if auto_bins_with_weights:
+            msg = (
+                "`bins` cannot be 'auto' when using weights. "
+                "Setting `bins=10`, but you will likely want to adjust."
+            )
+            warnings.warn(msg, UserWarning)
+            estimate_kws["bins"] = 10
+
+        # Check for log scaling on the data axis
+        data_axis = getattr(ax, f"{self.data_variable}axis")
+        log_scale = data_axis.get_scale() == "log"
+
+        # Simplify downstream code if we are not normalizing
+        if estimate_kws["stat"] == "count":
+            common_norm = False
+
+        # Now initialize the Histogram estimator
+        estimator = Histogram(**estimate_kws)
+        histograms = {}
+
+        # Define relevant columns
+        # Note that this works around an issue in core that we can fix, and
+        # then we won't need this messiness.
+        # https://github.com/mwaskom/seaborn/issues/2135
+        cols = list(self.variables)
+
+        # Do pre-compute housekeeping related to multiple groups
+        if "hue" in self.variables:
+
+            all_data = self.comp_data[cols].dropna()
+
+            if common_bins:
+                all_observations = all_data[self.data_variable]
+                estimator.define_bin_edges(
+                    all_observations,
+                    weights=all_data.get("weights", None),
+                )

         else:
+            multiple = None
+            common_norm = False

-            # All densities will start at 0
-            baselines = {k: np.zeros_like(v) for k, v in densities.items()}
+        # Estimate the smoothed kernel densities, for use later
+        if kde:
+            kde_kws.setdefault("cut", 0)
+            kde_kws["cumulative"] = estimate_kws["cumulative"]
+            densities = self._compute_univariate_density(
+                self.data_variable,
+                common_norm,
+                common_bins,
+                kde_kws,
+                log_scale,
+            )
+
+        # First pass through the data to compute the histograms
+        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+
+            # Prepare the relevant data
+            key = tuple(sub_vars.items())
+            sub_data = sub_data[cols].dropna()
+            observations = sub_data[self.data_variable]
+
+            if "weights" in self.variables:
+                weights = sub_data["weights"]
+            else:
+                weights = None
+
+            # Do the histogram computation
+            heights, edges = estimator(observations, weights=weights)
+
+            # Rescale the smoothed curve to match the histogram
+            if kde and key in densities:
+                density = densities[key]
+                if estimator.cumulative:
+                    hist_norm = heights.max()
+                else:
+                    hist_norm = (heights * np.diff(edges)).sum()
+                densities[key] *= hist_norm
+
+            # Convert edges back to original units for plotting
+            if log_scale:
+                edges = np.power(10, edges)
+
+            # Pack the histogram data and metadata together
+            index = pd.MultiIndex.from_arrays([
+                pd.Index(edges[:-1], name="edges"),
+                pd.Index(np.diff(edges) * shrink, name="widths"),
+            ])
+            hist = pd.Series(heights, index=index, name="heights")
+
+            # Apply scaling to normalize across groups
+            if common_norm:
+                hist *= len(sub_data) / len(all_data)
+
+            # Store the finalized histogram data for future plotting
+            histograms[key] = hist
+
+        # Modify the histogram and density data to resolve multiple groups
+        histograms, baselines = self._resolve_multiple(histograms, multiple)
+        if kde:
+            densities, _ = self._resolve_multiple(
+                densities, None if multiple == "dodge" else multiple
+            )
+
+        # Set autoscaling-related meta
+        sticky_stat = (0, 1) if multiple == "fill" else (0, np.inf)
+        if multiple == "fill":
+            # Filled plots should not have any margins
+            bin_vals = histograms.index.to_frame()
+            edges = bin_vals["edges"]
+            widths = bin_vals["widths"]
+            sticky_data = (
+                edges.min(),
+                edges.max() + widths.loc[edges.idxmax()]
+            )
+        else:
+            sticky_data = []
+
+        # --- Handle default visual attributes
+
+        # Note: default linewidth is determined after plotting
+
+        # Default color without a hue semantic should follow the color cycle
+        # Note, this is fairly complicated and awkward, I'd like a better way
+        if "hue" not in self.variables:
+            if fill:
+                if self.var_types[self.data_variable] == "datetime":
+                    # Avoid drawing empty fill_between on date axis
+                    # https://github.com/matplotlib/matplotlib/issues/17586
+                    default_color = plot_kws.pop(
+                        "color", plot_kws.pop("facecolor", "C0")
+                    )
+                    scout = None
+                else:
+                    artist = mpl.patches.Rectangle
+                    plot_kws = _normalize_kwargs(plot_kws, artist)
+                    scout = ax.fill_between([], [], **plot_kws)
+                    default_color = tuple(scout.get_facecolor().squeeze())
+                    plot_kws.pop("color", None)
+            else:
+                artist = mpl.lines.Line2D
+                plot_kws = _normalize_kwargs(plot_kws, artist)
+                scout, = ax.plot([], [], **plot_kws)
+                default_color = scout.get_color()
+            if scout is not None:
+                scout.remove()
+
+        # Defeat alpha should depend on other parameters
+        if multiple == "layer":
+            default_alpha = .5 if element == "bars" else .25
+        elif kde:
+            default_alpha = .5
+        else:
+            default_alpha = .75
+        alpha = plot_kws.pop("alpha", default_alpha)  # TODO make parameter?
+
+        hist_artists = []
+
+        # Go back through the dataset and draw the plots
+        for sub_vars, _ in self._semantic_subsets("hue", reverse=True):
+
+            key = tuple(sub_vars.items())
+            hist = histograms[key].rename("heights").reset_index()
+            bottom = np.asarray(baselines[key])
+
+            # Define the matplotlib attributes that depend on semantic mapping
+            if "hue" in self.variables:
+                color = self._hue_map(sub_vars["hue"])
+            else:
+                color = default_color
+
+            artist_kws = self._artist_kws(
+                plot_kws, fill, element, multiple, color, alpha
+            )
+
+            if element == "bars":
+
+                # Use matplotlib bar plotting
+
+                plot_func = ax.bar if self.data_variable == "x" else ax.barh
+                move = .5 * (1 - shrink)
+                artists = plot_func(
+                    hist["edges"] + move,
+                    hist["heights"] - bottom,
+                    hist["widths"],
+                    bottom,
+                    align="edge",
+                    **artist_kws,
+                )
+                for bar in artists:
+                    if self.data_variable == "x":
+                        bar.sticky_edges.x[:] = sticky_data
+                        bar.sticky_edges.y[:] = sticky_stat
+                    else:
+                        bar.sticky_edges.x[:] = sticky_stat
+                        bar.sticky_edges.y[:] = sticky_data
+
+                hist_artists.extend(artists)
+
+            else:
+
+                # Use either fill_between or plot to draw hull of histogram
+                if element == "step":
+
+                    final = hist.iloc[-1]
+                    x = np.append(hist["edges"], final["edges"] + final["widths"])
+                    y = np.append(hist["heights"], final["heights"])
+                    b = np.append(bottom, bottom[-1])
+
+                    step = "post"
+                    drawstyle = "steps-post"
+
+                elif element == "poly":
+
+                    x = hist["edges"] + hist["widths"] / 2
+                    y = hist["heights"]
+                    b = bottom
+
+                    step = None
+                    drawstyle = None
+
+                if self.data_variable == "x":
+                    if fill:
+                        artist = ax.fill_between(x, b, y, step=step, **artist_kws)
+                    else:
+                        artist, = ax.plot(x, y, drawstyle=drawstyle, **artist_kws)
+                    artist.sticky_edges.x[:] = sticky_data
+                    artist.sticky_edges.y[:] = sticky_stat
+                else:
+                    if fill:
+                        artist = ax.fill_betweenx(x, b, y, step=step, **artist_kws)
+                    else:
+                        artist, = ax.plot(y, x, drawstyle=drawstyle, **artist_kws)
+                    artist.sticky_edges.x[:] = sticky_stat
+                    artist.sticky_edges.y[:] = sticky_data
+
+                hist_artists.append(artist)
+
+            if kde:
+
+                # Add in the density curves
+
+                try:
+                    density = densities[key]
+                except KeyError:
+                    continue
+                support = density.index
+
+                if "x" in self.variables:
+                    line_args = support, density
+                    sticky_x, sticky_y = None, (0, np.inf)
+                else:
+                    line_args = density, support
+                    sticky_x, sticky_y = (0, np.inf), None
+
+                line_kws["color"] = to_rgba(color, 1)
+                line, = ax.plot(
+                    *line_args, **line_kws,
+                )
+
+                if sticky_x is not None:
+                    line.sticky_edges.x[:] = sticky_x
+                if sticky_y is not None:
+                    line.sticky_edges.y[:] = sticky_y
+
+        if element == "bars" and "linewidth" not in plot_kws:
+
+            # Now we handle linewidth, which depends on the scaling of the plot
+
+            # Needed in some cases to get valid transforms.
+            # Innocuous in other cases?
+            ax.autoscale_view()
+
+            # We will base everything on the minimum bin width
+            hist_metadata = [h.index.to_frame() for _, h in histograms.items()]
+            binwidth = min([
+                h["widths"].min() for h in hist_metadata
+            ])
+
+            # Convert binwidtj from data coordinates to pixels
+            pts_x, pts_y = 72 / ax.figure.dpi * (
+                ax.transData.transform([binwidth, binwidth])
+                - ax.transData.transform([0, 0])
+            )
+            if self.data_variable == "x":
+                binwidth_points = pts_x
+            else:
+                binwidth_points = pts_y
+
+            # The relative size of the lines depends on the appearance
+            # This is a provisional value and may need more tweaking
+            default_linewidth = .1 * binwidth_points
+
+            # Set the attributes
+            for bar in hist_artists:
+
+                # Don't let the lines get too thick
+                max_linewidth = bar.get_linewidth()
+                if not fill:
+                    max_linewidth *= 1.5
+
+                linewidth = min(default_linewidth, max_linewidth)
+
+                # If not filling, don't let lines dissapear
+                if not fill:
+                    min_linewidth = .5
+                    linewidth = max(linewidth, min_linewidth)
+
+                bar.set_linewidth(linewidth)
+
+        # --- Finalize the plot ----
+
+        # Axis labels
+        default_x = default_y = ""
+        if self.data_variable == "x":
+            default_y = estimator.stat.capitalize()
+        if self.data_variable == "y":
+            default_x = estimator.stat.capitalize()
+        self._add_axis_labels(ax, default_x, default_y)
+
+        # Legend for semantic variables
+        if "hue" in self.variables and legend:
+
+            if fill or element == "bars":
+                artist = partial(mpl.patches.Patch)
+            else:
+                artist = partial(mpl.lines.Line2D, [], [])
+
+            self._add_legend(
+                ax, artist, fill, element, multiple, alpha, plot_kws, {},
+            )
+
+    def plot_bivariate_histogram(
+        self,
+        common_bins,
+        common_norm,
+        thresh,
+        pthresh,
+        pmax,
+        color,
+        legend,
+        cbar, cbar_ax, cbar_kws,
+        estimate_kws,
+        plot_kws,
+        ax,
+    ):
+
+        # Check for log scaling on the data axis
+        log_scale = ax.xaxis.get_scale() == "log", ax.yaxis.get_scale() == "log"
+
+        # Now initialize the Histogram estimator
+        estimator = Histogram(**estimate_kws)
+
+        # None that we need to define cols because of some limitations in
+        # the core code, that are on track for resolution. (GH2135)
+        cols = list(self.variables)
+        all_data = self.comp_data[cols].dropna()
+        weights = all_data.get("weights", None)
+
+        # Do pre-compute housekeeping related to multiple groups
+        if "hue" in self.variables:
+            if common_bins:
+                estimator.define_bin_edges(
+                    all_data["x"],
+                    all_data["y"],
+                    weights,
+                )
+        else:
+            common_norm = False
+
+        # -- Determine colormap threshold and norm based on the full data
+
+        full_heights, _ = estimator(all_data["x"], all_data["y"], weights)
+
+        common_color_norm = "hue" not in self.variables or common_norm
+
+        if pthresh is not None and common_color_norm:
+            thresh = self._quantile_to_level(full_heights, pthresh)
+
+        plot_kws.setdefault("vmin", 0)
+        if common_color_norm:
+            if pmax is not None:
+                vmax = self._quantile_to_level(full_heights, pmax)
+            else:
+                vmax = plot_kws.pop("vmax", full_heights.max())
+        else:
+            vmax = None
+
+        # pcolormesh is going to turn the grid off, but we want to keep it
+        # I'm not sure if there's a better way to get the grid state
+        x_grid = any([l.get_visible() for l in ax.xaxis.get_gridlines()])
+        y_grid = any([l.get_visible() for l in ax.yaxis.get_gridlines()])
+
+        # Get a default color
+        if color is None:
+            color = "C0"
+
+        # --- Loop over data (subsets) and draw the histograms
+        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):
+
+            sub_data = sub_data[cols].dropna()
+
+            if sub_data.empty:
+                continue
+
+            # Do the histogram computation
+            heights, (x_edges, y_edges) = estimator(
+                sub_data["x"],
+                sub_data["y"],
+                weights=sub_data.get("weights", None),
+            )
+
+            if log_scale[0]:
+                x_edges = np.power(10, x_edges)
+            if log_scale[1]:
+                y_edges = np.power(10, y_edges)
+
+            # Apply scaling to normalize across groups
+            if estimator.stat != "count" and common_norm:
+                heights *= len(sub_data) / len(all_data)
+
+            # Define the specific kwargs for this artist
+            artist_kws = plot_kws.copy()
+            if "hue" in self.variables:
+                color = self._hue_map(sub_vars["hue"])
+                cmap = self._cmap_from_color(color)
+                artist_kws["cmap"] = cmap
+            else:
+                if "cmap" not in artist_kws:
+                    cmap = self._cmap_from_color(color)
+                    artist_kws["cmap"] = cmap
+
+            # Set the upper norm on the colormap
+            if not common_color_norm and pmax is not None:
+                vmax = self._quantile_to_level(heights, pmax)
+            if vmax is not None:
+                artist_kws["vmax"] = vmax
+
+            # Make cells at or below the threshold transparent
+            if not common_color_norm and pthresh:
+                thresh = self._quantile_to_level(heights, pthresh)
+            if thresh is not None:
+                heights = np.ma.masked_less_equal(heights, thresh)
+
+            # Draw the plot
+            mesh = ax.pcolormesh(
+                x_edges,
+                y_edges,
+                heights.T,
+                **artist_kws,
+            )
+
+            # Add an optional colorbar
+            # Note, we want to improve this. When hue is used, it will stack
+            # multiple colorbars with redundant ticks in an ugly way.
+            # But it's going to take some work to have multiple colorbars that
+            # share ticks nicely.
+            if cbar:
+                ax.figure.colorbar(mesh, cbar_ax, ax, **cbar_kws)
+
+        # --- Finalize the plot
+        if x_grid:
+            ax.grid(True, axis="x")
+        if y_grid:
+            ax.grid(True, axis="y")
+
+        self._add_axis_labels(ax)
+
+        if "hue" in self.variables and legend:
+
+            # TODO if possible, I would like to move the contour
+            # intensity information into the legend too and label the
+            # iso proportions rather than the raw density values
+
+            artist_kws = {}
+            artist = partial(mpl.patches.Patch)
+            self._add_legend(
+                ax, artist, True, False, "layer", 1, artist_kws, {},
+            )
+
+    def plot_univariate_density(
+        self,
+        multiple,
+        common_norm,
+        common_grid,
+        fill,
+        legend,
+        estimate_kws,
+        plot_kws,
+        ax,
+    ):
+
+        # Preprocess the matplotlib keyword dictionaries
+        if fill:
+            artist = mpl.collections.PolyCollection
+        else:
+            artist = mpl.lines.Line2D
+        plot_kws = _normalize_kwargs(plot_kws, artist)
+
+        # Input checking
+        _check_argument("multiple", ["layer", "stack", "fill"], multiple)
+
+        # Check for log scaling on the data axis
+        data_axis = getattr(ax, f"{self.data_variable}axis")
+        log_scale = data_axis.get_scale() == "log"
+
+        # Always share the evaluation grid when stacking
+        if "hue" in self.variables and multiple in ("stack", "fill"):
+            common_grid = True
+
+        # Do the computation
+        densities = self._compute_univariate_density(
+            self.data_variable,
+            common_norm,
+            common_grid,
+            estimate_kws,
+            log_scale
+        )
+
+        # Note: raises when no hue and multiple != layer. A problem?
+        densities, baselines = self._resolve_multiple(densities, multiple)
+
+        # Control the interaction with autoscaling by defining sticky_edges
+        # i.e. we don't want autoscale margins below the density curve
+        sticky_density = (0, 1) if multiple == "fill" else (0, np.inf)

-        # Filled plots should not have any margins
         if multiple == "fill":
+            # Filled plots should not have any margins
             sticky_support = densities.index.min(), densities.index.max()
         else:
             sticky_support = []
@@ -222,18 +862,27 @@ def plot_univariate(
         # Handle default visual attributes
         if "hue" not in self.variables:
             if fill:
-                scout = ax.fill_between([], [], **plot_kws)
-                default_color = tuple(scout.get_facecolor().squeeze())
+                if self.var_types[self.data_variable] == "datetime":
+                    # Avoid drawing empty fill_between on date axis
+                    # https://github.com/matplotlib/matplotlib/issues/17586
+                    default_color = plot_kws.pop(
+                        "color", plot_kws.pop("facecolor", "C0")
+                    )
+                    scout = None
+                else:
+                    scout = ax.fill_between([], [], **plot_kws)
+                    default_color = tuple(scout.get_facecolor().squeeze())
                 plot_kws.pop("color", None)
             else:
                 scout, = ax.plot([], [], **plot_kws)
                 default_color = scout.get_color()
-            scout.remove()
+            if scout is not None:
+                scout.remove()

         default_alpha = .25 if multiple == "layer" else .75
         alpha = plot_kws.pop("alpha", default_alpha)  # TODO make parameter?

-        # Now iterate through again and draw the densities
+        # Now iterate through the subsets and draw the densities
         # We go backwards so stacked densities read from top-to-bottom
         for sub_vars, _ in self._semantic_subsets("hue", reverse=True):

@@ -253,10 +902,10 @@ def plot_univariate(
                 color = default_color

             artist_kws = self._artist_kws(
-                plot_kws, fill, multiple, color, alpha
+                plot_kws, fill, False, multiple, color, alpha
             )

-            # Plot a curve with observation values on the x axis
+            # Either plot a curve with observation values on the x axis
             if "x" in self.variables:

                 if fill:
@@ -269,7 +918,7 @@ def plot_univariate(
                 artist.sticky_edges.x[:] = sticky_support
                 artist.sticky_edges.y[:] = sticky_density

-            # Plot a curve with observation values on the y axis
+            # Or plot a curve with observation values on the y axis
             else:
                 if fill:
                     artist = ax.fill_betweenx(
@@ -283,9 +932,9 @@ def plot_univariate(

         # --- Finalize the plot ----
         default_x = default_y = ""
-        if data_variable == "x":
+        if self.data_variable == "x":
             default_y = "Density"
-        if data_variable == "y":
+        if self.data_variable == "y":
             default_x = "Density"
         self._add_axis_labels(ax, default_x, default_y)

@@ -297,18 +946,17 @@ def plot_univariate(
                 artist = partial(mpl.lines.Line2D, [], [])

             self._add_legend(
-                ax, artist, fill, multiple, alpha, plot_kws, {},
+                ax, artist, fill, False, multiple, alpha, plot_kws, {},
             )

-    def plot_bivariate(
+    def plot_bivariate_density(
         self,
         common_norm,
         fill,
         levels,
         thresh,
-        legend,
-        log_scale,
         color,
+        legend,
         cbar,
         cbar_ax,
         cbar_kws,
@@ -324,19 +972,23 @@ def plot_bivariate(
         if "hue" not in self.variables:
             common_norm = False

+        # See other notes about GH2135
+        cols = list(self.variables)
+        all_data = self.plot_data[cols].dropna()
+
+        # Check for log scaling on iether axis
+        scalex = ax.xaxis.get_scale() == "log"
+        scaley = ax.yaxis.get_scale() == "log"
+        log_scale = scalex, scaley
+
         # Loop through the subsets and estimate the KDEs
         densities, supports = {}, {}

-        for sub_vars, sub_data in self._semantic_subsets("hue"):
+        for sub_vars, sub_data in self._semantic_subsets("hue", from_comp_data=True):

             # Extract the data points from this sub set and remove nulls
-            observations = remove_na(sub_data[["x", "y"]])
-
-            observation_variance = observations.var().any()
-            if not observation_variance or np.isnan(observation_variance):
-                msg = "Dataset has 0 variance; skipping density estimate."
-                warnings.warn(msg, UserWarning)
-                continue
+            sub_data = sub_data[cols].dropna()
+            observations = sub_data[["x", "y"]]

             # Extract the weights for this subset of observations
             if "weights" in self.variables:
@@ -344,13 +996,6 @@ def plot_bivariate(
             else:
                 weights = None

-            # If data axis is log scaled, fit the KDE in logspace
-            if log_scale is not None:
-                if log_scale[0]:
-                    observations["x"] = np.log10(observations["x"])
-                if log_scale[1]:
-                    observations["y"] = np.log10(observations["y"])
-
             # Check that KDE will not error out
             variance = observations[["x", "y"]].var()
             if not variance.all() or variance.isna().any():
@@ -373,124 +1018,426 @@ def plot_bivariate(

             # Apply a scaling factor so that the integral over all subsets is 1
             if common_norm:
-                density *= len(sub_data) / len(self.plot_data)
+                density *= len(sub_data) / len(all_data)
+
+            key = tuple(sub_vars.items())
+            densities[key] = density
+            supports[key] = support
+
+        # Define a grid of iso-proportion levels
+        if isinstance(levels, Number):
+            levels = np.linspace(thresh, 1, levels)
+        else:
+            if min(levels) < 0 or max(levels) > 1:
+                raise ValueError("levels must be in [0, 1]")
+
+        # Transfrom from iso-proportions to iso-densities
+        if common_norm:
+            common_levels = self._quantile_to_level(
+                list(densities.values()), levels,
+            )
+            draw_levels = {k: common_levels for k in densities}
+        else:
+            draw_levels = {
+                k: self._quantile_to_level(d, levels)
+                for k, d in densities.items()
+            }
+
+        # Get a default single color from the attribute cycle
+        scout, = ax.plot([], color=color)
+        default_color = scout.get_color()
+        scout.remove()
+
+        # Define the coloring of the contours
+        if "hue" in self.variables:
+            for param in ["cmap", "colors"]:
+                if param in contour_kws:
+                    msg = f"{param} parameter ignored when using hue mapping."
+                    warnings.warn(msg, UserWarning)
+                    contour_kws.pop(param)
+        else:
+            coloring_given = set(contour_kws) & {"cmap", "colors"}
+            if fill and not coloring_given:
+                cmap = self._cmap_from_color(default_color)
+                contour_kws["cmap"] = cmap
+            if not fill and not coloring_given:
+                contour_kws["colors"] = [default_color]
+
+        # Choose the function to plot with
+        # TODO could add a pcolormesh based option as well
+        # Which would look something like element="raster"
+        if fill:
+            contour_func = ax.contourf
+        else:
+            contour_func = ax.contour
+
+        # Loop through the subsets again and plot the data
+        for sub_vars, _ in self._semantic_subsets("hue"):
+
+            if "hue" in sub_vars:
+                color = self._hue_map(sub_vars["hue"])
+                if fill:
+                    contour_kws["cmap"] = self._cmap_from_color(color)
+                else:
+                    contour_kws["colors"] = [color]
+
+            key = tuple(sub_vars.items())
+            if key not in densities:
+                continue
+            density = densities[key]
+            xx, yy = supports[key]
+
+            label = contour_kws.pop("label", None)
+
+            cset = contour_func(
+                xx, yy, density,
+                levels=draw_levels[key],
+                **contour_kws,
+            )
+
+            if "hue" not in self.variables:
+                cset.collections[0].set_label(label)
+
+            # Add a color bar representing the contour heights
+            # Note: this shows iso densities, not iso proportions
+            # See more notes in histplot about how this could be improved
+            if cbar:
+                cbar_kws = {} if cbar_kws is None else cbar_kws
+                ax.figure.colorbar(cset, cbar_ax, ax, **cbar_kws)
+
+        # --- Finalize the plot
+        self._add_axis_labels(ax)
+
+        if "hue" in self.variables and legend:
+
+            # TODO if possible, I would like to move the contour
+            # intensity information into the legend too and label the
+            # iso proportions rather than the raw density values
+
+            artist_kws = {}
+            if fill:
+                artist = partial(mpl.patches.Patch)
+            else:
+                artist = partial(mpl.lines.Line2D, [], [])
+
+            self._add_legend(
+                ax, artist, fill, False, "layer", 1, artist_kws, {},
+            )
+
+    def plot_rug(self, height, expand_margins, legend, ax, kws):
+
+        kws = _normalize_kwargs(kws, mpl.lines.Line2D)
+
+        # TODO we need to abstract this logic
+        scout, = ax.plot([], [], **kws)
+        kws["color"] = kws.pop("color", scout.get_color())
+        scout.remove()
+
+        kws.setdefault("linewidth", 1)
+
+        if expand_margins:
+            xmarg, ymarg = ax.margins()
+            if "x" in self.variables:
+                ymarg += height * 2
+            if "y" in self.variables:
+                xmarg += height * 2
+            ax.margins(x=xmarg, y=ymarg)
+
+        if "hue" in self.variables:
+            kws.pop("c", None)
+            kws.pop("color", None)
+
+        if "x" in self.variables:
+            self._plot_single_rug("x", height, ax, kws)
+        if "y" in self.variables:
+            self._plot_single_rug("y", height, ax, kws)
+
+        # --- Finalize the plot
+        self._add_axis_labels(ax)
+        if "hue" in self.variables and legend:
+            # TODO ideally i'd like the legend artist to look like a rug
+            legend_artist = partial(mpl.lines.Line2D, [], [])
+            self._add_legend(
+                ax, legend_artist, False, False, None, 1, {}, {},
+            )
+
+    def _plot_single_rug(self, var, height, ax, kws):
+        """Draw a rugplot along one axis of the plot."""
+        vector = self.plot_data[var]
+        n = len(vector)
+
+        # We'll always add a single collection with varying colors
+        if "hue" in self.variables:
+            colors = self._hue_map(self.plot_data["hue"])
+        else:
+            colors = None
+
+        # Build the array of values for the LineCollection
+        if var == "x":
+
+            trans = tx.blended_transform_factory(ax.transData, ax.transAxes)
+            xy_pairs = np.column_stack([
+                np.repeat(vector, 2), np.tile([0, height], n)
+            ])
+
+        if var == "y":
+
+            trans = tx.blended_transform_factory(ax.transAxes, ax.transData)
+            xy_pairs = np.column_stack([
+                np.tile([0, height], n), np.repeat(vector, 2)
+            ])
+
+        # Draw the lines on the plot
+        line_segs = xy_pairs.reshape([n, 2, 2])
+        ax.add_collection(LineCollection(
+            line_segs, transform=trans, colors=colors, **kws
+        ))
+
+        ax.autoscale_view(scalex=var == "x", scaley=var == "y")
+
+
+# ==================================================================================== #
+# External API
+# ==================================================================================== #
+
+def histplot(
+    data=None, *,
+    # Vector variables
+    x=None, y=None, hue=None, weights=None,
+    # Histogram computation parameters
+    stat="count", bins="auto", binwidth=None, binrange=None,
+    discrete=None, cumulative=False, common_bins=True, common_norm=True,
+    # Histogram appearance parameters
+    multiple="layer", element="bars", fill=True, shrink=1,
+    # Histogram smoothing with a kernel density estimate
+    kde=False, kde_kws=None, line_kws=None,
+    # Bivariate histogram parameters
+    thresh=0, pthresh=None, pmax=None, cbar=False, cbar_ax=None, cbar_kws=None,
+    # Hue mapping parameters
+    palette=None, hue_order=None, hue_norm=None, color=None,
+    # Axes information
+    log_scale=None, legend=True, ax=None,
+    # Other appearance keywords
+    **kwargs,
+):
+
+    p = _DistributionPlotter(
+        data=data,
+        variables=_DistributionPlotter.get_semantics(locals())
+    )
+
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
+
+    if ax is None:
+        ax = plt.gca()
+
+    # TODO move these defaults inside the plot functions
+    if kde_kws is None:
+        kde_kws = {}
+
+    if line_kws is None:
+        line_kws = {}
+
+    if cbar_kws is None:
+        cbar_kws = {}
+
+    # Check for a specification that lacks x/y data and return early
+    if not p.has_xy_data:
+        return ax

-            key = tuple(sub_vars.items())
-            densities[key] = density
-            supports[key] = support
+    # Attach the axes to the plotter, setting up unit conversions
+    p._attach(ax, log_scale=log_scale)

-        # Define a grid of iso-proportion levels
-        if isinstance(levels, Number):
-            levels = np.linspace(thresh, 1, levels)
+    # Default to discrete bins for categorical variables
+    # Note that having this logic here may constrain plans for distplot
+    # It can move inside the plot_ functions, it will just need to modify
+    # the estimate_kws dictionary (I am not sure how we feel about that)
+    if discrete is None:
+        if p.univariate:
+            discrete = p.var_types[p.data_variable] == "categorical"
         else:
-            if min(levels) < 0 or max(levels) > 1:
-                raise ValueError("levels must be in [0, 1]")
+            discrete_x = p.var_types["x"] == "categorical"
+            discrete_y = p.var_types["y"] == "categorical"
+            discrete = discrete_x, discrete_y

-        # Transfrom from iso-proportions to iso-densities
-        if common_norm:
-            common_levels = self._find_contour_levels(
-                list(densities.values()), levels,
-            )
-            draw_levels = {k: common_levels for k in densities}
-        else:
-            draw_levels = {
-                k: self._find_contour_levels(d, levels)
-                for k, d in densities.items()
-            }
+    estimate_kws = dict(
+        stat=stat,
+        bins=bins,
+        binwidth=binwidth,
+        binrange=binrange,
+        discrete=discrete,
+        cumulative=cumulative,
+    )

-        # Get a default single color from the attribute cycle
-        scout, = ax.plot([], color=color)
-        default_color = scout.get_color()
-        scout.remove()
+    if p.univariate:

-        # Apply a common color-mapping to single color specificiations
-        color_map = partial(light_palette, reverse=True, as_cmap=True)
+        if "hue" not in p.variables:
+            kwargs["color"] = color

-        # Define the coloring of the contours
-        if "hue" in self.variables:
-            for param in ["cmap", "colors"]:
-                if param in contour_kws:
-                    msg = f"{param} parameter ignored when using hue mapping."
-                    warnings.warn(msg, UserWarning)
-                    contour_kws.pop(param)
-        else:
-            coloring_given = set(contour_kws) & {"cmap", "colors"}
-            if fill and not coloring_given:
-                cmap = color_map(default_color)
-                contour_kws["cmap"] = cmap
-            if not fill and not coloring_given:
-                contour_kws["colors"] = [default_color]
+        p.plot_univariate_histogram(
+            multiple=multiple,
+            element=element,
+            fill=fill,
+            shrink=shrink,
+            common_norm=common_norm,
+            common_bins=common_bins,
+            kde=kde,
+            kde_kws=kde_kws.copy(),
+            color=color,
+            legend=legend,
+            estimate_kws=estimate_kws.copy(),
+            line_kws=line_kws.copy(),
+            plot_kws=kwargs,
+            ax=ax,
+        )

-        # Choose the function to plot with
-        # TODO could add a pcolormesh based option as well
-        if fill:
-            contour_func = ax.contourf
-        else:
-            contour_func = ax.contour
+    else:

-        # Loop through the subsets again and plot the data
-        for sub_vars, _ in self._semantic_subsets("hue"):
+        p.plot_bivariate_histogram(
+            common_bins=common_bins,
+            common_norm=common_norm,
+            thresh=thresh,
+            pthresh=pthresh,
+            pmax=pmax,
+            color=color,
+            legend=legend,
+            cbar=cbar,
+            cbar_ax=cbar_ax,
+            cbar_kws=cbar_kws,
+            estimate_kws=estimate_kws,
+            plot_kws=kwargs,
+            ax=ax,
+        )

-            if "hue" in sub_vars:
-                color = self._hue_map(sub_vars["hue"])
-                if fill:
-                    contour_kws["cmap"] = color_map(color)
-                else:
-                    contour_kws["colors"] = [color]
+    return ax

-            key = tuple(sub_vars.items())
-            if key not in densities:
-                continue
-            density = densities[key]
-            xx, yy = supports[key]

-            label = contour_kws.pop("label", None)
+histplot.__doc__ = """\
+Plot univeriate or bivariate histograms to show distributions of datasets.

-            cset = contour_func(
-                xx, yy, density,
-                levels=draw_levels[key],
-                **contour_kws,
-            )
+A histogram is a classic visualization tool that represents the distribution
+of one or more variables by counting the number of observations that fall within
+disrete bins.

-            if "hue" not in self.variables:
-                cset.collections[0].set_label(label)
+This function can normalize the statistic computed within each bin to estimate
+frequency, density or probability mass, and it can add a smooth curve obtained
+using a kernel density estimate, similar to :func:`kdeplot`.

-        # Add a color bar representing the contour heights
-        # Note: this shows iso densities, not iso proportions
-        if cbar:
-            # TODO what to do about hue here?
-            # TODO maybe use the legend instead?
-            cbar_kws = {} if cbar_kws is None else cbar_kws
-            ax.figure.colorbar(cset, cbar_ax, ax, **cbar_kws)
+More information is provided in the :ref:`user guide <userguide_hist>`.

-        # --- Finalize the plot
-        self._add_axis_labels(ax)
+Parameters
+----------
+{params.core.data}
+{params.core.xy}
+{params.core.hue}
+weights : vector or key in ``data``
+    If provided, weight the contribution of the corresponding data points
+    towards the count in each bin by these factors.
+{params.hist.stat}
+{params.hist.bins}
+{params.hist.binwidth}
+{params.hist.binrange}
+discrete : bool
+    If True, default to ``binwidth=1`` and draw the bars so that they are
+    centered on their corresponding data points. This avoids "gaps" that may
+    otherwise appear when using discrete (integer) data.
+cumulative : bool
+    If True, plot the cumulative counts as bins increase.
+common_bins : bool
+    If True, use the same bins when semantic variables produce multiple
+    plots. If using a reference rule to determine the bins, it will be computed
+    with the full dataset.
+common_norm : bool
+    If True and using a normalized statistic, the normalization will apply over
+    the full dataset. Otherwise, normalize each histogram independently.
+multiple : {{"layer", "dodge", "stack", "fill"}}
+    Approach to resolving multiple elements when semantic mapping creates subsets.
+    Only relevant with univariate data.
+element : {{"bars", "step", "poly"}}
+    Visual representation of the histogram statistic.
+    Only relevant with univariate data.
+fill : bool
+    If True, fill in the space under the histogram.
+    Only relevant with univariate data.
+shrink : number
+    Scale the width of each bar relative to the binwidth by this factor.
+    Only relevant with univariate data.
+kde : bool
+    If True, compute a kernel density estimate to smooth the distribution
+    and show on the plot as (one or more) line(s).
+    Only relevant with univariate data.
+kde_kws : dict
+    Parameters that control the KDE computation, as in :func:`kdeplot`.
+line_kws : dict
+    Parameters that control the KDE visualization, passed to
+    :meth:`matplotlib.axes.Axes.plot`.
+thresh : number or None
+    Cells with a statistic less than or equal to this value will be transparent.
+    Only relevant with bivariate data.
+pthresh : number or None
+    Like ``thresh``, but a value in [0, 1] such that cells with aggregate counts
+    (or other statistics, when used) up to this proportion of the total will be
+    transparent.
+pmax : number or None
+    A value in [0, 1] that sets that saturation point for the colormap at a value
+    such that cells below is constistute this proportion of the total count (or
+    other statistic, when used).
+{params.dist.cbar}
+{params.dist.cbar_ax}
+{params.dist.cbar_kws}
+{params.core.palette}
+{params.core.hue_order}
+{params.core.hue_norm}
+{params.core.color}
+{params.dist.log_scale}
+{params.dist.legend}
+{params.core.ax}
+kwargs
+    Other keyword arguments are passed to one of the following matplotlib
+    functions:

-        if "hue" in self.variables and legend:
+    - :meth:`matplotlib.axes.Axes.bar` (univariate, element="bars")
+    - :meth:`matplotlib.axes.Axes.fill_between` (univariate, other element, fill=True)
+    - :meth:`matplotlib.axes.Axes.plot` (univariate, other element, fill=False)
+    - :meth:`matplotlib.axes.Axes.pcolormesh` (bivariate)

-            # TODO if possible, I would like to move the contour
-            # intensity information into the legend too and label the
-            # iso proportions rather than the raw density values
+Returns
+-------
+{returns.ax}

-            artist_kws = {}
-            if fill:
-                artist = partial(mpl.patches.Patch)
-            else:
-                artist = partial(mpl.lines.Line2D, [], [])
+See Also
+--------
+{seealso.kdeplot}
+{seealso.rugplot}
+{seealso.jointplot}
+distplot

-            self._add_legend(
-                ax, artist, fill, "layer", 1, artist_kws, {},
-            )
+Notes
+-----

-    def _find_contour_levels(self, density, isoprop):
-        """Return contour levels to draw density at given iso-propotions."""
-        isoprop = np.asarray(isoprop)
-        values = np.ravel(density)
-        sorted_values = np.sort(values)[::-1]
-        normalized_values = np.cumsum(sorted_values) / values.sum()
-        idx = np.searchsorted(normalized_values, 1 - isoprop)
-        levels = np.take(sorted_values, idx, mode="clip")
-        return levels
+The choice of bins for computing and plotting a histogram can exert
+substantial influence on the insights that one is able to draw from the
+visualization. If the bins are too large, they may erase important features.
+On the other hand, bins that are too small may be dominated by random
+variability, obscuring the shape of the true underlying distribution. The
+default bin size is determined using a reference rule that depends on the
+sample size and variance. This works well in many cases, (i.e., with
+"well-behaved" data) but it fails in others. It is always a good to try
+different bin sizes to be sure that you are not missing something important.
+This function allows you to specify bins in several different ways, such as
+by setting the total number of bins to use, the width of each bin, or the
+specific locations where the bins should break.
+
+Examples
+--------
+
+.. include:: ../docstrings/histplot.rst
+
+""".format(
+    params=_param_docs,
+    returns=_core_docs["returns"],
+    seealso=_core_docs["seealso"],
+)

 @_deprecate_positional_args
@@ -601,9 +1548,9 @@ def kdeplot(

     # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - #

-    p = _KDEPlotter(
+    p = _DistributionPlotter(
         data=data,
-        variables=_KDEPlotter.get_semantics(locals()),
+        variables=_DistributionPlotter.get_semantics(locals()),
     )

     p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
@@ -611,6 +1558,10 @@ def kdeplot(
     if ax is None:
         ax = plt.gca()

+    # Check for a specification that lacks x/y data and return early
+    if not p.has_xy_data:
+        return ax
+
     # Pack the kwargs for statistics.KDE
     estimate_kws = dict(
         bw_method=bw_method,
@@ -621,34 +1572,9 @@ def kdeplot(
         cumulative=cumulative,
     )

-    # Check for a specification that lacks x/y data and return early
-    any_data = bool({"x", "y"} & set(p.variables))
-    if not any_data:
-        return ax
-
-    # Determine the kind of plot to use
-    univariate = bool({"x", "y"} - set(p.variables))
-
-    if univariate:
-
-        data_variable = (set(p.variables) & {"x", "y"}).pop()
-
-        # Catch some inputs we cannot do anything with
-        data_var_type = p.var_types[data_variable]
-        if data_var_type != "numeric":
-            msg = (
-                f"kdeplot requires a numeric '{data_variable}' variable, "
-                f"but a {data_var_type} was passed."
-            )
-            raise TypeError(msg)
+    p._attach(ax, allowed_types=["numeric", "datetime"], log_scale=log_scale)

-        # Possibly log scale the data axis
-        if log_scale is not None:
-            set_scale = getattr(ax, f"set_{data_variable}scale")
-            if log_scale is True:
-                set_scale("log")
-            else:
-                set_scale("log", **{f"base{data_variable}": log_scale})
+    if p.univariate:

         # Set defaults that depend on other parameters
         if fill is None:
@@ -658,7 +1584,7 @@ def kdeplot(
         if color is not None:
             plot_kws["color"] = color

-        p.plot_univariate(
+        p.plot_univariate_density(
             multiple=multiple,
             common_norm=common_norm,
             common_grid=common_grid,
@@ -671,40 +1597,12 @@ def kdeplot(

     else:

-        # Check input types
-        for var in "xy":
-            var_type = p.var_types[var]
-            if var_type != "numeric":
-                msg = (
-                    f"kdeplot requires a numeric '{var}' variable, "
-                    f"but a {var_type} was passed."
-                )
-                raise TypeError(msg)
-
-        # Possibly log-scale one or both axes
-        if log_scale is not None:
-            # Allow single value or x, y tuple
-            try:
-                scalex, scaley = log_scale
-            except TypeError:
-                scalex = scaley = log_scale
-                log_scale = scalex, scaley  # Tupelize for downstream
-
-            for axis, scale in zip("xy", (scalex, scaley)):
-                if scale:
-                    set_scale = getattr(ax, f"set_{axis}scale")
-                    if scale is True:
-                        set_scale("log")
-                    else:
-                        set_scale("log", **{f"base{axis}": scale})
-
-        p.plot_bivariate(
+        p.plot_bivariate_density(
             common_norm=common_norm,
             fill=fill,
             levels=levels,
             thresh=thresh,
             legend=legend,
-            log_scale=log_scale,
             color=color,
             cbar=cbar,
             cbar_ax=cbar_ax,
@@ -760,8 +1658,7 @@ def kdeplot(
     Number of points on each dimension of the evaluation grid.
 {params.kde.cut}
 {params.kde.clip}
-legend : bool
-    If False, suppress the legend for semantic variables.
+{params.dist.legend}
 {params.kde.cumulative}
 shade_lowest : bool
     If False, the area below the lowest contour will be transparent
@@ -769,13 +1666,9 @@ def kdeplot(
     .. deprecated:: 0.11.0
        see ``thresh``.

-cbar : bool
-    If True, add a colorbar to annotate the contour levels. Only relevant
-    with bivariate data.
-cbar_ax : :class:`matplotlib.axes.Axes`
-    Pre-existing axes for the colorbar.
-cbar_kws : dict
-    Additional parameters passed to :meth:`matplotlib.figure.Figure.colorbar`.
+{params.dist.cbar}
+{params.dist.cbar_ax}
+{params.dist.cbar_kws}
 {params.core.ax}
 weights : vector or key in ``data``
     If provided, perform weighted kernel density estimation.
@@ -783,9 +1676,7 @@ def kdeplot(
 {params.core.palette}
 {params.core.hue_order}
 {params.core.hue_norm}
-multiple : {{"layer", "stack", "fill"}}
-    Method for drawing multiple elements when semantic mapping creates subsets.
-    Only relevant with univariate data.
+{params.dist.multiple}
 common_norm : bool
     If True, scale each conditional density by the number of observations
     such that the total area under all densities sums to 1. Otherwise,
@@ -803,9 +1694,7 @@ def kdeplot(
     ``levels`` is a vector. Only relevant with bivariate data.
 {params.kde.bw_method}
 {params.kde.bw_adjust}
-log_scale : bool or number, or pair of bools or numbers
-    Set a log scale on the data axis (or axes, with bivariate data) with the
-    given base (default 10), and evaluate the KDE in log space.
+{params.dist.log_scale}
 {params.core.color}
 fill : bool or None
     If True, fill in the area under univariate density curves or between
@@ -826,6 +1715,7 @@ def kdeplot(

 See Also
 --------
+{seealso.histplot}
 {seealso.rugplot}
 {seealso.violinplot}
 {seealso.jointplot}
@@ -876,88 +1766,6 @@ def kdeplot(
 )

-class _RugPlotter(_DistributionPlotter):
-
-    def __init__(
-        self,
-        data=None,
-        variables={},
-    ):
-
-        super().__init__(data=data, variables=variables)
-
-    def plot(self, height, expand_margins, legend, ax, kws):
-
-        kws = _normalize_kwargs(kws, mpl.lines.Line2D)
-
-        # TODO we need to abstract this logic
-        scout, = ax.plot([], [], **kws)
-        kws["color"] = kws.pop("color", scout.get_color())
-        scout.remove()
-
-        kws.setdefault("linewidth", 1)
-
-        if expand_margins:
-            xmarg, ymarg = ax.margins()
-            if "x" in self.variables:
-                ymarg += height * 2
-            if "y" in self.variables:
-                xmarg += height * 2
-            ax.margins(x=xmarg, y=ymarg)
-
-        if "hue" in self.variables:
-            kws.pop("c", None)
-            kws.pop("color", None)
-
-        if "x" in self.variables:
-            self._plot_single_rug("x", height, ax, kws)
-        if "y" in self.variables:
-            self._plot_single_rug("y", height, ax, kws)
-
-        # --- Finalize the plot
-        self._add_axis_labels(ax)
-        if "hue" in self.variables and legend:
-            # TODO ideally i'd like the legend artist to look like a rug
-            legend_artist = partial(mpl.lines.Line2D, [], [])
-            self._add_legend(
-                ax, legend_artist, False, None, 1, {}, {},
-            )
-
-    def _plot_single_rug(self, var, height, ax, kws):
-        """Draw a rugplot along one axis of the plot."""
-        vector = self.plot_data[var]
-        n = len(vector)
-
-        # We'll always add a single collection with varying colors
-        if "hue" in self.variables:
-            colors = self._hue_map(self.plot_data["hue"])
-        else:
-            colors = None
-
-        # Build the array of values for the LineCollection
-        if var == "x":
-
-            trans = tx.blended_transform_factory(ax.transData, ax.transAxes)
-            xy_pairs = np.column_stack([
-                np.repeat(vector, 2), np.tile([0, height], n)
-            ])
-
-        if var == "y":
-
-            trans = tx.blended_transform_factory(ax.transAxes, ax.transData)
-            xy_pairs = np.column_stack([
-                np.tile([0, height], n), np.repeat(vector, 2)
-            ])
-
-        # Draw the lines on the plot
-        line_segs = xy_pairs.reshape([n, 2, 2])
-        ax.add_collection(LineCollection(
-            line_segs, transform=trans, colors=colors, **kws
-        ))
-
-        ax.autoscale_view(scalex=var == "x", scaley=var == "y")
-
-
 @_deprecate_positional_args
 def rugplot(
     x=None,  # Allow positional x, because behavior won't change
@@ -976,9 +1784,16 @@ def rugplot(
     **kwargs
 ):

+    # A note: I think it would make sense to add multiple= to rugplot and allow
+    # rugs for different hue variables to be shifted orthogonal to the data axis
+    # But is this stacking, or dodging?
+
     # A note: if we want to add a style semantic to rugplot,
     # we could make an option that draws the rug using scatterplot

+    # A note, it would also be nice to offer some kind of histogram/density
+    # rugplot, since alpha blending doesn't work great in the large n regime
+
     # Handle deprecation of `a``
     if a is not None:
         msg = "The `a` parameter is now called `x`. Please update your code."
@@ -1005,15 +1820,19 @@ def rugplot(

     # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - #

-    variables = _RugPlotter.get_semantics(locals())
-
-    p = _RugPlotter(data=data, variables=variables)
+    weights = None
+    p = _DistributionPlotter(
+        data=data,
+        variables=_DistributionPlotter.get_semantics(locals()),
+    )
     p.map_hue(palette=palette, order=hue_order, norm=hue_norm)

     if ax is None:
         ax = plt.gca()

-    p.plot(height, expand_margins, legend, ax, kwargs)
+    p._attach(ax)
+
+    p.plot_rug(height, expand_margins, legend, ax, kwargs)

     return ax

diff --git a/seaborn/utils.py b/seaborn/utils.py
index 6788a754b8..7dc50fa0c4 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -668,3 +668,11 @@ def _normalize_kwargs(kws, artist):
     except AttributeError:
         kws = normalize_kwargs(kws, _alias_map)
     return kws
+
+
+def _check_argument(param, options, value):
+    """Raise if value for param is not in options."""
+    if value not in options:
+        raise ValueError(
+            f"`{param}` must be one of {options}, but {value} was passed.`"
+        )
diff --git a/setup.cfg b/setup.cfg
index 347b373a39..5fe3a51f96 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,5 +2,6 @@
 license_file = LICENSE

 [flake8]
+max-line-length = 88
 exclude = seaborn/cm.py,seaborn/external
 ignore = E741,F522,W503
diff --git a/setup.py b/setup.py
index f955deb648..473fba74fe 100644
--- a/setup.py
+++ b/setup.py
@@ -30,10 +30,10 @@
 PYTHON_REQUIRES = ">=3.6"

 INSTALL_REQUIRES = [
-    'numpy>=1.13',
+    'numpy>=1.15',
     'scipy>=1.0',
     'pandas>=0.23',
-    'matplotlib>=2.1',
+    'matplotlib>=2.2',
 ]

diff --git a/seaborn/conftest.py b/seaborn/conftest.py
index 2a1c291240..b88c1497de 100644
--- a/seaborn/conftest.py
+++ b/seaborn/conftest.py
@@ -137,7 +137,7 @@ def long_df(rng):
         y=rng.normal(size=n),
         a=rng.choice(list("abc"), n),
         b=rng.choice(list("mnop"), n),
-        c=rng.choice([0, 1], n),
+        c=rng.choice([0, 1], n, [.3, .7]),
         t=np.repeat(np.datetime64('2005-02-25'), n),
         s=rng.choice([2, 4, 8], n),
         f=rng.choice([0.2, 0.3], n),
diff --git a/seaborn/tests/test_axisgrid.py b/seaborn/tests/test_axisgrid.py
index 4735c323fc..1e41e2b0b7 100644
--- a/seaborn/tests/test_axisgrid.py
+++ b/seaborn/tests/test_axisgrid.py
@@ -9,6 +9,7 @@
 import pytest
 import nose.tools as nt
 import numpy.testing as npt
+from numpy.testing import assert_array_equal
 try:
     import pandas.testing as tm
 except ImportError:
@@ -17,7 +18,7 @@
 from .._core import categorical_order
 from .. import rcmod
 from ..palettes import color_palette
-from ..distributions import kdeplot, _freedman_diaconis_bins
+from ..distributions import kdeplot
 from ..categorical import pointplot
 from .. import axisgrid as ag

@@ -1419,66 +1420,62 @@ class TestJointPlot(object):
     def test_scatter(self):

         g = ag.jointplot(x="x", y="y", data=self.data)
-        nt.assert_equal(len(g.ax_joint.collections), 1)
+        assert len(g.ax_joint.collections) == 1

         x, y = g.ax_joint.collections[0].get_offsets().T
-        npt.assert_array_equal(self.x, x)
-        npt.assert_array_equal(self.y, y)
+        assert_array_equal(self.x, x)
+        assert_array_equal(self.y, y)

-        x_bins = _freedman_diaconis_bins(self.x)
-        nt.assert_equal(len(g.ax_marg_x.patches), x_bins)
+        assert_array_equal(
+            [b.get_x() for b in g.ax_marg_x.patches],
+            np.histogram_bin_edges(self.x, "auto")[:-1],
+        )

-        y_bins = _freedman_diaconis_bins(self.y)
-        nt.assert_equal(len(g.ax_marg_y.patches), y_bins)
+        assert_array_equal(
+            [b.get_y() for b in g.ax_marg_y.patches],
+            np.histogram_bin_edges(self.y, "auto")[:-1],
+        )

     def test_reg(self):

         g = ag.jointplot(x="x", y="y", data=self.data, kind="reg")
-        nt.assert_equal(len(g.ax_joint.collections), 2)
+        assert len(g.ax_joint.collections) == 2

         x, y = g.ax_joint.collections[0].get_offsets().T
-        npt.assert_array_equal(self.x, x)
-        npt.assert_array_equal(self.y, y)
+        assert_array_equal(self.x, x)
+        assert_array_equal(self.y, y)

-        x_bins = _freedman_diaconis_bins(self.x)
-        nt.assert_equal(len(g.ax_marg_x.patches), x_bins)
+        assert g.ax_marg_x.patches
+        assert g.ax_marg_y.patches

-        y_bins = _freedman_diaconis_bins(self.y)
-        nt.assert_equal(len(g.ax_marg_y.patches), y_bins)
-
-        nt.assert_equal(len(g.ax_joint.lines), 1)
-        nt.assert_equal(len(g.ax_marg_x.lines), 1)
-        nt.assert_equal(len(g.ax_marg_y.lines), 1)
+        assert g.ax_marg_x.lines
+        assert g.ax_marg_y.lines

     def test_resid(self):

         g = ag.jointplot(x="x", y="y", data=self.data, kind="resid")
-        nt.assert_equal(len(g.ax_joint.collections), 1)
-        nt.assert_equal(len(g.ax_joint.lines), 1)
-        nt.assert_equal(len(g.ax_marg_x.lines), 0)
-        nt.assert_equal(len(g.ax_marg_y.lines), 1)
+        assert g.ax_joint.collections
+        assert g.ax_joint.lines
+        assert not g.ax_marg_x.lines
+        assert g.ax_marg_y.lines

     def test_hex(self):

         g = ag.jointplot(x="x", y="y", data=self.data, kind="hex")
-        nt.assert_equal(len(g.ax_joint.collections), 1)
-
-        x_bins = _freedman_diaconis_bins(self.x)
-        nt.assert_equal(len(g.ax_marg_x.patches), x_bins)
-
-        y_bins = _freedman_diaconis_bins(self.y)
-        nt.assert_equal(len(g.ax_marg_y.patches), y_bins)
+        assert g.ax_joint.collections
+        assert g.ax_marg_x.patches
+        assert g.ax_marg_y.patches

     def test_kde(self):

         g = ag.jointplot(x="x", y="y", data=self.data, kind="kde")

-        nt.assert_true(len(g.ax_joint.collections) > 0)
-        nt.assert_equal(len(g.ax_marg_x.collections), 1)
-        nt.assert_equal(len(g.ax_marg_y.collections), 1)
+        assert len(g.ax_joint.collections) > 0
+        assert len(g.ax_marg_x.collections) == 1
+        assert len(g.ax_marg_y.collections) == 1

-        nt.assert_equal(len(g.ax_marg_x.collections), 1)
-        nt.assert_equal(len(g.ax_marg_y.collections), 1)
+        assert len(g.ax_marg_x.collections) == 1
+        assert len(g.ax_marg_y.collections) == 1

     def test_color(self):

@@ -1486,33 +1483,33 @@ def test_color(self):

         purple = mpl.colors.colorConverter.to_rgb("purple")
         scatter_color = g.ax_joint.collections[0].get_facecolor()[0, :3]
-        nt.assert_equal(tuple(scatter_color), purple)
+        assert tuple(scatter_color) == purple

         hist_color = g.ax_marg_x.patches[0].get_facecolor()[:3]
-        nt.assert_equal(hist_color, purple)
+        assert hist_color == purple

     def test_annotation(self):

         with pytest.warns(UserWarning):
             g = ag.jointplot(x="x", y="y", data=self.data,
                              stat_func=stats.pearsonr)
-        nt.assert_equal(len(g.ax_joint.legend_.get_texts()), 1)
+        assert len(g.ax_joint.legend_.get_texts()) == 1

         g = ag.jointplot(x="x", y="y", data=self.data, stat_func=None)
-        nt.assert_is(g.ax_joint.legend_, None)
+        assert g.ax_joint.legend_ is None

     def test_hex_customise(self):

         # test that default gridsize can be overridden
         g = ag.jointplot(x="x", y="y", data=self.data, kind="hex",
                          joint_kws=dict(gridsize=5))
-        nt.assert_equal(len(g.ax_joint.collections), 1)
+        assert len(g.ax_joint.collections) == 1
         a = g.ax_joint.collections[0].get_array()
-        nt.assert_equal(28, a.shape[0])  # 28 hexagons expected for gridsize 5
+        assert a.shape[0] == 28  # 28 hexagons expected for gridsize 5

     def test_bad_kind(self):

-        with nt.assert_raises(ValueError):
+        with pytest.raises(ValueError):
             ag.jointplot(x="x", y="y", data=self.data, kind="not_a_kind")

     def test_leaky_dict(self):
diff --git a/seaborn/tests/test_core.py b/seaborn/tests/test_core.py
index 11c70bcf04..2aa9798aa7 100644
--- a/seaborn/tests/test_core.py
+++ b/seaborn/tests/test_core.py
@@ -801,6 +801,146 @@ def test_axis_labels(self, long_df):
         assert ax.get_xlabel() == "existing"
         assert ax.get_ylabel() == "also existing"

+    @pytest.mark.parametrize(
+        "variables",
+        [
+            dict(x="x", y="y"),
+            dict(x="x"),
+            dict(y="y"),
+            dict(x="t", y="y"),
+            dict(x="x", y="a"),
+        ]
+    )
+    def test_attach_basics(self, long_df, variables):
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables=variables)
+        p._attach(ax)
+        assert p.ax is ax
+
+    def test_attach_disallowed(self, long_df):
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "a"})
+
+        with pytest.raises(TypeError):
+            p._attach(ax, allowed_types="numeric")
+
+        with pytest.raises(TypeError):
+            p._attach(ax, allowed_types=["datetime", "numeric"])
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x"})
+
+        with pytest.raises(TypeError):
+            p._attach(ax, allowed_types="categorical")
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "t"})
+
+        with pytest.raises(TypeError):
+            p._attach(ax, allowed_types=["numeric", "categorical"])
+
+    def test_attach_log_scale(self, long_df):
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x"})
+        p._attach(ax, log_scale=True)
+        assert ax.xaxis.get_scale() == "log"
+        assert ax.yaxis.get_scale() == "linear"
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x"})
+        p._attach(ax, log_scale=2)
+        assert ax.xaxis.get_scale() == "log"
+        assert ax.yaxis.get_scale() == "linear"
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"y": "y"})
+        p._attach(ax, log_scale=True)
+        assert ax.xaxis.get_scale() == "linear"
+        assert ax.yaxis.get_scale() == "log"
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
+        p._attach(ax, log_scale=True)
+        assert ax.xaxis.get_scale() == "log"
+        assert ax.yaxis.get_scale() == "log"
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
+        p._attach(ax, log_scale=(True, False))
+        assert ax.xaxis.get_scale() == "log"
+        assert ax.yaxis.get_scale() == "linear"
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
+        p._attach(ax, log_scale=(False, 2))
+        assert ax.xaxis.get_scale() == "linear"
+        assert ax.yaxis.get_scale() == "log"
+
+    def test_attach_converters(self, long_df):
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "t"})
+        p._attach(ax)
+        assert ax.xaxis.converter is None
+        assert isinstance(ax.yaxis.converter, mpl.dates.DateConverter)
+
+        _, ax = plt.subplots()
+        p = VectorPlotter(data=long_df, variables={"x": "a", "y": "y"})
+        p._attach(ax)
+        assert isinstance(ax.xaxis.converter, mpl.category.StrCategoryConverter)
+        assert ax.yaxis.converter is None
+
+    def test_comp_data(self, long_df):
+
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "t"})
+
+        with pytest.raises(AttributeError):
+            p.comp_data
+
+        _, ax = plt.subplots()
+        p._attach(ax)
+
+        assert_array_equal(p.comp_data["x"], p.plot_data["x"])
+        assert_array_equal(
+            p.comp_data["y"], ax.yaxis.convert_units(p.plot_data["y"])
+        )
+
+        p = VectorPlotter(data=long_df, variables={"x": "a"})
+
+        _, ax = plt.subplots()
+        p._attach(ax)
+
+        assert_array_equal(
+            p.comp_data["x"], ax.xaxis.convert_units(p.plot_data["x"])
+        )
+
+    def test_comp_data_log(self, long_df):
+
+        p = VectorPlotter(data=long_df, variables={"x": "x", "y": "y"})
+        _, ax = plt.subplots()
+        p._attach(ax, log_scale=(True, False))
+
+        assert_array_equal(
+            p.comp_data["x"], np.log10(p.plot_data["x"])
+        )
+        assert_array_equal(p.comp_data["y"], p.plot_data["y"])
+
+    def test_comp_data_category_order(self):
+
+        s = (pd.Series(["a", "b", "c", "a"], dtype="category")
+             .cat.set_categories(["b", "c", "a"], ordered=True))
+
+        p = VectorPlotter(variables={"x": s})
+        _, ax = plt.subplots()
+        p._attach(ax)
+        assert_array_equal(
+            p.comp_data["x"],
+            [2, 0, 1, 2],
+        )
+

 class TestCoreFunc:

diff --git a/seaborn/tests/test_distributions.py b/seaborn/tests/test_distributions.py
index 124c981e2e..efcbc0dbf0 100644
--- a/seaborn/tests/test_distributions.py
+++ b/seaborn/tests/test_distributions.py
@@ -20,10 +20,12 @@
     categorical_order,
 )
 from .._statistics import (
-    KDE
+    KDE,
+    Histogram,
 )
 from ..distributions import (
-    _KDEPlotter,
+    _DistributionPlotter,
+    histplot,
     rugplot,
     kdeplot,
 )
@@ -302,11 +304,11 @@ def test_empty_data(self):
     def test_singular_data(self):

         with pytest.warns(UserWarning):
-            ax = dist.kdeplot(x=np.ones(10))
+            ax = kdeplot(x=np.ones(10))
         assert not ax.lines

         with pytest.warns(UserWarning):
-            ax = dist.kdeplot(x=[5])
+            ax = kdeplot(x=[5])
         assert not ax.lines

     def test_variable_assignment(self, long_df):
@@ -500,9 +502,9 @@ def test_color(self, long_df):
             == to_rgba(color, alpha)
         )

-    def test_multiple_input_check(self, long_df):
+    def test_multiple_argument_check(self, long_df):

-        with pytest.raises(ValueError, match="multiple must be"):
+        with pytest.raises(ValueError, match="`multiple` must be"):
             kdeplot(data=long_df, x="x", hue="a", multiple="bad_input")

     def test_cut(self, rng):
@@ -734,17 +736,7 @@ def test_line_kws(self, flat_array):

     def test_input_checking(self, long_df):

-        err = (
-            "kdeplot requires a numeric 'x' variable, "
-            "but a datetime was passed"
-        )
-        with pytest.raises(TypeError, match=err):
-            kdeplot(data=long_df, x="t")
-
-        err = (
-            "kdeplot requires a numeric 'x' variable, "
-            "but a categorical was passed"
-        )
+        err = "The x variable is categorical,"
         with pytest.raises(TypeError, match=err):
             kdeplot(data=long_df, x="a")

@@ -845,20 +837,20 @@ def test_log_scale(self, rng):

         levels = .2, .5, 1

-        f, (ax1, ax2) = plt.subplots(ncols=2)
-        kdeplot(x=x, y=y, log_scale=True, levels=levels, ax=ax1)
-        assert ax1.get_xscale() == "log"
-        assert ax1.get_yscale() == "log"
+        f, ax = plt.subplots()
+        kdeplot(x=x, y=y, log_scale=True, levels=levels, ax=ax)
+        assert ax.get_xscale() == "log"
+        assert ax.get_yscale() == "log"

         f, (ax1, ax2) = plt.subplots(ncols=2)
         kdeplot(x=x, y=y, log_scale=(10, False), levels=levels, ax=ax1)
         assert ax1.get_xscale() == "log"
         assert ax1.get_yscale() == "linear"

-        p = _KDEPlotter()
+        p = _DistributionPlotter()
         kde = KDE()
         density, (xx, yy) = kde(np.log10(x), y)
-        levels = p._find_contour_levels(density, levels)
+        levels = p._quantile_to_level(density, levels)
         ax2.contour(10 ** xx, yy, density, levels=levels)

         for c1, c2 in zip(ax1.collections, ax2.collections):
@@ -951,16 +943,831 @@ def test_levels_and_thresh(self, long_df):
         with pytest.raises(ValueError):
             kdeplot(**plot_kws, levels=[0, 1, 2])

-    def test_contour_levels(self, rng):
+    def test_quantile_to_level(self, rng):

         x = rng.uniform(0, 1, 100000)
         isoprop = np.linspace(.1, 1, 6)

-        levels = _KDEPlotter()._find_contour_levels(x, isoprop)
+        levels = _DistributionPlotter()._quantile_to_level(x, isoprop)
         for h, p in zip(levels, isoprop):
             assert (x[x <= h].sum() / x.sum()) == pytest.approx(p, abs=1e-4)

     def test_input_checking(self, long_df):

-        with pytest.raises(TypeError, match="kdeplot requires a numeric 'x'"):
+        with pytest.raises(TypeError, match="The x variable is categorical,"):
             kdeplot(data=long_df, x="a", y="y")
+
+
+class TestHistPlotUnivariate:
+
+    @pytest.mark.parametrize(
+        "variable", ["x", "y"],
+    )
+    def test_long_vectors(self, long_df, variable):
+
+        vector = long_df[variable]
+        vectors = [
+            variable, vector, np.asarray(vector), vector.tolist(),
+        ]
+
+        f, axs = plt.subplots(3)
+        for vector, ax in zip(vectors, axs):
+            histplot(data=long_df, ax=ax, **{variable: vector})
+
+        bars = [ax.patches for ax in axs]
+        for a_bars, b_bars in itertools.product(bars, bars):
+            for a, b in zip(a_bars, b_bars):
+                assert_array_equal(a.get_height(), b.get_height())
+                assert_array_equal(a.get_xy(), b.get_xy())
+
+    def test_wide_vs_long_data(self, wide_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        histplot(data=wide_df, ax=ax1, common_bins=False)
+
+        for col in wide_df.columns[::-1]:
+            histplot(data=wide_df, x=col, ax=ax2)
+
+        for a, b in zip(ax1.patches, ax2.patches):
+            assert a.get_height() == b.get_height()
+            assert a.get_xy() == b.get_xy()
+
+    def test_flat_vector(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        histplot(data=long_df["x"], ax=ax1)
+        histplot(data=long_df, x="x", ax=ax2)
+
+        for a, b in zip(ax1.patches, ax2.patches):
+            assert a.get_height() == b.get_height()
+            assert a.get_xy() == b.get_xy()
+
+    def test_empty_data(self):
+
+        ax = histplot(x=[])
+        assert not ax.patches
+
+    def test_variable_assignment(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        histplot(data=long_df, x="x", ax=ax1)
+        histplot(data=long_df, y="x", ax=ax2)
+
+        for a, b in zip(ax1.patches, ax2.patches):
+            assert a.get_height() == b.get_width()
+
+    @pytest.mark.parametrize("element", ["bars", "step", "poly"])
+    @pytest.mark.parametrize("multiple", ["layer", "dodge", "stack", "fill"])
+    def test_hue_fill_colors(self, long_df, multiple, element):
+
+        ax = histplot(
+            data=long_df, x="x", hue="a",
+            multiple=multiple, bins=1,
+            fill=True, element=element, legend=False,
+        )
+
+        palette = color_palette()
+
+        if multiple == "layer":
+            if element == "bars":
+                a = .5
+            else:
+                a = .25
+        else:
+            a = .75
+
+        for bar, color in zip(ax.patches[::-1], palette):
+            assert bar.get_facecolor() == to_rgba(color, a)
+
+        for poly, color in zip(ax.collections[::-1], palette):
+            assert tuple(poly.get_facecolor().squeeze()) == to_rgba(color, a)
+
+    def test_hue_stack(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        n = 10
+
+        kws = dict(data=long_df, x="x", hue="a", bins=n, element="bars")
+
+        histplot(**kws, multiple="layer", ax=ax1)
+        histplot(**kws, multiple="stack", ax=ax2)
+
+        layer_heights = np.reshape([b.get_height() for b in ax1.patches], (-1, n))
+        stack_heights = np.reshape([b.get_height() for b in ax2.patches], (-1, n))
+        assert_array_equal(layer_heights, stack_heights)
+
+        stack_xys = np.reshape([b.get_xy() for b in ax2.patches], (-1, n, 2))
+        assert_array_equal(
+            stack_xys[..., 1] + stack_heights,
+            stack_heights.cumsum(axis=0),
+        )
+
+    def test_hue_fill(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        n = 10
+
+        kws = dict(data=long_df, x="x", hue="a", bins=n, element="bars")
+
+        histplot(**kws, multiple="layer", ax=ax1)
+        histplot(**kws, multiple="fill", ax=ax2)
+
+        layer_heights = np.reshape([b.get_height() for b in ax1.patches], (-1, n))
+        stack_heights = np.reshape([b.get_height() for b in ax2.patches], (-1, n))
+        assert_array_almost_equal(
+            layer_heights / layer_heights.sum(axis=0), stack_heights
+        )
+
+        stack_xys = np.reshape([b.get_xy() for b in ax2.patches], (-1, n, 2))
+        assert_array_almost_equal(
+            (stack_xys[..., 1] + stack_heights) / stack_heights.sum(axis=0),
+            stack_heights.cumsum(axis=0),
+        )
+
+    def test_hue_dodge(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        bw = 2
+
+        kws = dict(data=long_df, x="x", hue="c", binwidth=bw, element="bars")
+
+        histplot(**kws, multiple="layer", ax=ax1)
+        histplot(**kws, multiple="dodge", ax=ax2)
+
+        layer_heights = [b.get_height() for b in ax1.patches]
+        dodge_heights = [b.get_height() for b in ax2.patches]
+        assert_array_equal(layer_heights, dodge_heights)
+
+        layer_xs = np.reshape([b.get_x() for b in ax1.patches], (2, -1))
+        dodge_xs = np.reshape([b.get_x() for b in ax2.patches], (2, -1))
+        assert_array_almost_equal(layer_xs[1], dodge_xs[1])
+        assert_array_almost_equal(layer_xs[0], dodge_xs[0] - bw / 2)
+
+    def test_multiple_input_check(self, flat_series):
+
+        with pytest.raises(ValueError, match="`multiple` must be"):
+            histplot(flat_series, multiple="invalid")
+
+    def test_element_input_check(self, flat_series):
+
+        with pytest.raises(ValueError, match="`element` must be"):
+            histplot(flat_series, element="invalid")
+
+    def test_count_stat(self, flat_series):
+
+        ax = histplot(flat_series, stat="count")
+        bar_heights = [b.get_height() for b in ax.patches]
+        assert sum(bar_heights) == len(flat_series)
+
+    def test_density_stat(self, flat_series):
+
+        ax = histplot(flat_series, stat="density")
+        bar_heights = [b.get_height() for b in ax.patches]
+        bar_widths = [b.get_width() for b in ax.patches]
+        assert np.multiply(bar_heights, bar_widths).sum() == pytest.approx(1)
+
+    def test_density_stat_common_norm(self, long_df):
+
+        ax = histplot(
+            data=long_df, x="x", hue="a",
+            stat="density", common_norm=True, element="bars",
+        )
+        bar_heights = [b.get_height() for b in ax.patches]
+        bar_widths = [b.get_width() for b in ax.patches]
+        assert np.multiply(bar_heights, bar_widths).sum() == pytest.approx(1)
+
+    def test_density_stat_unique_norm(self, long_df):
+
+        n = 10
+        ax = histplot(
+            data=long_df, x="x", hue="a",
+            stat="density", bins=n, common_norm=False, element="bars",
+        )
+
+        bar_groups = ax.patches[:n], ax.patches[-n:]
+
+        for bars in bar_groups:
+            bar_heights = [b.get_height() for b in bars]
+            bar_widths = [b.get_width() for b in bars]
+            bar_areas = np.multiply(bar_heights, bar_widths)
+            assert bar_areas.sum() == pytest.approx(1)
+
+    def test_probability_stat(self, flat_series):
+
+        ax = histplot(flat_series, stat="probability")
+        bar_heights = [b.get_height() for b in ax.patches]
+        assert sum(bar_heights) == pytest.approx(1)
+
+    def test_probability_stat_common_norm(self, long_df):
+
+        ax = histplot(
+            data=long_df, x="x", hue="a",
+            stat="probability", common_norm=True, element="bars",
+        )
+        bar_heights = [b.get_height() for b in ax.patches]
+        assert sum(bar_heights) == pytest.approx(1)
+
+    def test_probability_stat_unique_norm(self, long_df):
+
+        n = 10
+        ax = histplot(
+            data=long_df, x="x", hue="a",
+            stat="probability", bins=n, common_norm=False, element="bars",
+        )
+
+        bar_groups = ax.patches[:n], ax.patches[-n:]
+
+        for bars in bar_groups:
+            bar_heights = [b.get_height() for b in bars]
+            assert sum(bar_heights) == pytest.approx(1)
+
+    def test_common_bins(self, long_df):
+
+        n = 10
+        ax = histplot(
+            long_df, x="x", hue="a", common_bins=True, bins=n, element="bars",
+        )
+
+        bar_groups = ax.patches[:n], ax.patches[-n:]
+        assert_array_equal(
+            [b.get_xy() for b in bar_groups[0]],
+            [b.get_xy() for b in bar_groups[1]]
+        )
+
+    def test_unique_bins(self, wide_df):
+
+        ax = histplot(wide_df, common_bins=False, bins=10, element="bars")
+
+        bar_groups = np.split(np.array(ax.patches), len(wide_df.columns))
+
+        for i, col in enumerate(wide_df.columns[::-1]):
+            bars = bar_groups[i]
+            start = bars[0].get_x()
+            stop = bars[-1].get_x() + bars[-1].get_width()
+            assert start == wide_df[col].min()
+            assert stop == wide_df[col].max()
+
+    def test_weights_with_missing(self, missing_df):
+
+        ax = histplot(missing_df, x="x", weights="s")
+
+        bar_heights = [bar.get_height() for bar in ax.patches]
+        total_weight = missing_df[["x", "s"]].dropna()["s"].sum()
+        assert sum(bar_heights) == pytest.approx(total_weight)
+
+    def test_discrete(self, long_df):
+
+        ax = histplot(long_df, x="s", discrete=True)
+
+        data_min = long_df["s"].min()
+        data_max = long_df["s"].max()
+        assert len(ax.patches) == (data_max - data_min + 1)
+
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_width() == 1
+            assert bar.get_x() == (data_min + i - .5)
+
+    def test_discrete_categorical_default(self, long_df):
+
+        ax = histplot(long_df, x="a")
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_width() == 1
+
+    def test_discrete_requires_bars(self, long_df):
+
+        with pytest.raises(ValueError, match="`element` must be 'bars'"):
+            histplot(long_df, x="s", discrete=True, element="poly")
+
+    @pytest.mark.parametrize("stat", ["count", "density", "probability"])
+    def test_kde(self, flat_series, stat):
+
+        ax = histplot(
+            flat_series, kde=True, stat=stat, kde_kws={"cut": 10}
+        )
+
+        bar_widths = [b.get_width() for b in ax.patches]
+        bar_heights = [b.get_height() for b in ax.patches]
+        hist_area = np.multiply(bar_widths, bar_heights).sum()
+
+        density, = ax.lines
+        kde_area = integrate.trapz(density.get_ydata(), density.get_xdata())
+
+        assert kde_area == pytest.approx(hist_area)
+
+    @pytest.mark.parametrize("multiple", ["layer", "dodge"])
+    @pytest.mark.parametrize("stat", ["count", "density", "probability"])
+    def test_kde_with_hue(self, long_df, stat, multiple):
+
+        n = 10
+        ax = histplot(
+            long_df, x="x", hue="c", multiple=multiple,
+            kde=True, stat=stat, element="bars",
+            kde_kws={"cut": 10}, bins=n,
+        )
+
+        bar_groups = ax.patches[:n], ax.patches[-n:]
+
+        for i, bars in enumerate(bar_groups):
+            bar_widths = [b.get_width() for b in bars]
+            bar_heights = [b.get_height() for b in bars]
+            hist_area = np.multiply(bar_widths, bar_heights).sum()
+
+            x, y = ax.lines[i].get_xydata().T
+            kde_area = integrate.trapz(y, x)
+
+            if multiple == "layer":
+                assert kde_area == pytest.approx(hist_area)
+            elif multiple == "dodge":
+                assert kde_area == pytest.approx(hist_area * 2)
+
+    def test_kde_default_cut(self, flat_series):
+
+        ax = histplot(flat_series, kde=True)
+        support = ax.lines[0].get_xdata()
+        assert support.min() == flat_series.min()
+        assert support.max() == flat_series.max()
+
+    def test_kde_hue(self, long_df):
+
+        n = 10
+        ax = histplot(data=long_df, x="x", hue="a", kde=True, bins=n)
+
+        for bar, line in zip(ax.patches[::n], ax.lines):
+            assert to_rgba(bar.get_facecolor(), 1) == line.get_color()
+
+    def test_kde_yaxis(self, flat_series):
+
+        f, ax = plt.subplots()
+        histplot(x=flat_series, kde=True)
+        histplot(y=flat_series, kde=True)
+
+        x, y = ax.lines
+        assert_array_equal(x.get_xdata(), y.get_ydata())
+        assert_array_equal(x.get_ydata(), y.get_xdata())
+
+    def test_kde_line_kws(self, flat_series):
+
+        lw = 5
+        ax = histplot(flat_series, kde=True, line_kws=dict(lw=lw))
+        assert ax.lines[0].get_linewidth() == lw
+
+    def test_kde_singular_data(self):
+
+        with pytest.warns(UserWarning):
+            ax = histplot(x=np.ones(10), kde=True)
+        assert not ax.lines
+
+        with pytest.warns(UserWarning):
+            ax = histplot(x=[5], kde=True)
+        assert not ax.lines
+
+    def test_element_default(self, long_df):
+
+        f, (ax1, ax2) = plt.subplots(2)
+        histplot(long_df, x="x", ax=ax1)
+        histplot(long_df, x="x", ax=ax2, element="bars")
+        assert len(ax1.patches) == len(ax2.patches)
+
+        f, (ax1, ax2) = plt.subplots(2)
+        histplot(long_df, x="x", hue="a", ax=ax1)
+        histplot(long_df, x="x", hue="a", ax=ax2, element="bars")
+        assert len(ax1.patches) == len(ax2.patches)
+
+    def test_bars_no_fill(self, flat_series):
+
+        ax = histplot(flat_series, element="bars", fill=False)
+        for bar in ax.patches:
+            assert bar.get_facecolor() == (0, 0, 0, 0)
+
+    def test_step_fill(self, flat_series):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        n = 10
+        histplot(flat_series, element="bars", fill=True, bins=n, ax=ax1)
+        histplot(flat_series, element="step", fill=True, bins=n, ax=ax2)
+
+        bar_heights = [b.get_height() for b in ax1.patches]
+        bar_widths = [b.get_width() for b in ax1.patches]
+        bar_edges = [b.get_x() for b in ax1.patches]
+
+        fill = ax2.collections[0]
+        x, y = fill.get_paths()[0].vertices[::-1].T
+
+        assert_array_equal(x[1:2 * n:2], bar_edges)
+        assert_array_equal(y[1:2 * n:2], bar_heights)
+
+        assert x[n * 2] == bar_edges[-1] + bar_widths[-1]
+        assert y[n * 2] == bar_heights[-1]
+
+    def test_poly_fill(self, flat_series):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        n = 10
+        histplot(flat_series, element="bars", fill=True, bins=n, ax=ax1)
+        histplot(flat_series, element="poly", fill=True, bins=n, ax=ax2)
+
+        bar_heights = np.array([b.get_height() for b in ax1.patches])
+        bar_widths = np.array([b.get_width() for b in ax1.patches])
+        bar_edges = np.array([b.get_x() for b in ax1.patches])
+
+        fill = ax2.collections[0]
+        x, y = fill.get_paths()[0].vertices[::-1].T
+
+        assert_array_equal(x[1:n + 1], bar_edges + bar_widths / 2)
+        assert_array_equal(y[1:n + 1], bar_heights)
+
+    def test_poly_no_fill(self, flat_series):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        n = 10
+        histplot(flat_series, element="bars", fill=False, bins=n, ax=ax1)
+        histplot(flat_series, element="poly", fill=False, bins=n, ax=ax2)
+
+        bar_heights = np.array([b.get_height() for b in ax1.patches])
+        bar_widths = np.array([b.get_width() for b in ax1.patches])
+        bar_edges = np.array([b.get_x() for b in ax1.patches])
+
+        x, y = ax2.lines[0].get_xydata().T
+
+        assert_array_equal(x, bar_edges + bar_widths / 2)
+        assert_array_equal(y, bar_heights)
+
+    def test_step_no_fill(self, flat_series):
+
+        f, (ax1, ax2) = plt.subplots(2)
+
+        histplot(flat_series, element="bars", fill=False, ax=ax1)
+        histplot(flat_series, element="step", fill=False, ax=ax2)
+
+        bar_heights = [b.get_height() for b in ax1.patches]
+        bar_widths = [b.get_width() for b in ax1.patches]
+        bar_edges = [b.get_x() for b in ax1.patches]
+
+        x, y = ax2.lines[0].get_xydata().T
+
+        assert_array_equal(x[:-1], bar_edges)
+        assert_array_equal(y[:-1], bar_heights)
+        assert x[-1] == bar_edges[-1] + bar_widths[-1]
+        assert y[-1] == y[-2]
+
+    def test_step_fill_xy(self, flat_series):
+
+        f, ax = plt.subplots()
+
+        histplot(x=flat_series, element="step", fill=True)
+        histplot(y=flat_series, element="step", fill=True)
+
+        xverts = ax.collections[0].get_paths()[0].vertices
+        yverts = ax.collections[1].get_paths()[0].vertices
+
+        assert_array_equal(xverts, yverts[:, ::-1])
+
+    def test_step_no_fill_xy(self, flat_series):
+
+        f, ax = plt.subplots()
+
+        histplot(x=flat_series, element="step", fill=False)
+        histplot(y=flat_series, element="step", fill=False)
+
+        xline, yline = ax.lines
+
+        assert_array_equal(xline.get_xdata(), yline.get_ydata())
+        assert_array_equal(xline.get_ydata(), yline.get_xdata())
+
+    def test_weighted_histogram(self):
+
+        ax = histplot(x=[0, 1, 2], weights=[1, 2, 3], discrete=True)
+
+        bar_heights = [b.get_height() for b in ax.patches]
+        assert bar_heights == [1, 2, 3]
+
+    def test_weights_with_auto_bins(self, long_df):
+
+        with pytest.warns(UserWarning):
+            ax = histplot(long_df, x="x", weights="f")
+        assert len(ax.patches) == 10
+
+    def test_shrink(self, long_df):
+
+        bw = 2
+        shrink = .5
+        ax = histplot(long_df, x="x", binwidth=bw, shrink=shrink)
+        assert ax.patches[0].get_width() == bw * shrink
+
+    def test_log_scale_explicit(self, rng):
+
+        x = rng.lognormal(0, 2, 1000)
+        ax = histplot(x, log_scale=True, binwidth=1)
+
+        bar_widths = [b.get_width() for b in ax.patches]
+        steps = np.divide(bar_widths[1:], bar_widths[:-1])
+        assert np.allclose(steps, 10)
+
+    def test_log_scale_implicit(self, rng):
+
+        x = rng.lognormal(0, 2, 1000)
+
+        f, ax = plt.subplots()
+        ax.set_xscale("log")
+        histplot(x, binwidth=1, ax=ax)
+
+        bar_widths = [b.get_width() for b in ax.patches]
+        steps = np.divide(bar_widths[1:], bar_widths[:-1])
+        assert np.allclose(steps, 10)
+
+    @pytest.mark.parametrize(
+        "fill", [True, False],
+    )
+    def test_auto_linewidth(self, flat_series, fill):
+
+        get_lw = lambda ax: ax.patches[0].get_linewidth()  # noqa: E731
+
+        kws = dict(element="bars", fill=fill)
+
+        f, (ax1, ax2) = plt.subplots(2)
+        histplot(flat_series, **kws, bins=10, ax=ax1)
+        histplot(flat_series, **kws, bins=100, ax=ax2)
+        assert get_lw(ax1) > get_lw(ax2)
+
+        f, ax1 = plt.subplots(figsize=(10, 5))
+        f, ax2 = plt.subplots(figsize=(2, 5))
+        histplot(flat_series, **kws, bins=30, ax=ax1)
+        histplot(flat_series, **kws, bins=30, ax=ax2)
+        assert get_lw(ax1) > get_lw(ax2)
+
+    def test_bar_kwargs(self, flat_series):
+
+        lw = 2
+        ec = (1, .2, .9, .5)
+        ax = histplot(flat_series, binwidth=1, ec=ec, lw=lw)
+        for bar in ax.patches:
+            assert bar.get_edgecolor() == ec
+            assert bar.get_linewidth() == lw
+
+    def test_step_fill_kwargs(self, flat_series):
+
+        lw = 2
+        ec = (1, .2, .9, .5)
+        ax = histplot(flat_series, element="step", ec=ec, lw=lw)
+        poly = ax.collections[0]
+        assert tuple(poly.get_edgecolor().squeeze()) == ec
+        assert poly.get_linewidth() == lw
+
+    def test_step_line_kwargs(self, flat_series):
+
+        lw = 2
+        ls = "--"
+        ax = histplot(flat_series, element="step", fill=False, lw=lw, ls=ls)
+        line = ax.lines[0]
+        assert line.get_linewidth() == lw
+        assert line.get_linestyle() == ls
+
+
+class TestHistPlotBivariate:
+
+    def test_mesh(self, long_df):
+
+        hist = Histogram()
+        counts, (x_edges, y_edges) = hist(long_df["x"], long_df["y"])
+
+        ax = histplot(long_df, x="x", y="y")
+        mesh = ax.collections[0]
+        mesh_data = mesh.get_array()
+
+        assert_array_equal(mesh_data.data, counts.T.flat)
+        assert_array_equal(mesh_data.mask, counts.T.flat == 0)
+
+        edges = itertools.product(y_edges[:-1], x_edges[:-1])
+        for i, (y, x) in enumerate(edges):
+            path = mesh.get_paths()[i]
+            assert path.vertices[0, 0] == x
+            assert path.vertices[0, 1] == y
+
+    def test_mesh_with_hue(self, long_df):
+
+        ax = histplot(long_df, x="x", y="y", hue="c")
+
+        hist = Histogram()
+        hist.define_bin_edges(long_df["x"], long_df["y"])
+
+        for i, sub_df in long_df.groupby("c"):
+
+            mesh = ax.collections[i]
+            mesh_data = mesh.get_array()
+
+            counts, (x_edges, y_edges) = hist(sub_df["x"], sub_df["y"])
+
+            assert_array_equal(mesh_data.data, counts.T.flat)
+            assert_array_equal(mesh_data.mask, counts.T.flat == 0)
+
+            edges = itertools.product(y_edges[:-1], x_edges[:-1])
+            for i, (y, x) in enumerate(edges):
+                path = mesh.get_paths()[i]
+                assert path.vertices[0, 0] == x
+                assert path.vertices[0, 1] == y
+
+    def test_mesh_with_hue_unique_bins(self, long_df):
+
+        ax = histplot(long_df, x="x", y="y", hue="c", common_bins=False)
+
+        for i, sub_df in long_df.groupby("c"):
+
+            hist = Histogram()
+
+            mesh = ax.collections[i]
+            mesh_data = mesh.get_array()
+
+            counts, (x_edges, y_edges) = hist(sub_df["x"], sub_df["y"])
+
+            assert_array_equal(mesh_data.data, counts.T.flat)
+            assert_array_equal(mesh_data.mask, counts.T.flat == 0)
+
+            edges = itertools.product(y_edges[:-1], x_edges[:-1])
+            for i, (y, x) in enumerate(edges):
+                path = mesh.get_paths()[i]
+                assert path.vertices[0, 0] == x
+                assert path.vertices[0, 1] == y
+
+    def test_mesh_log_scale(self, rng):
+
+        x, y = rng.lognormal(0, 1, (2, 1000))
+        hist = Histogram()
+        counts, (x_edges, y_edges) = hist(np.log10(x), np.log10(y))
+
+        ax = histplot(x=x, y=y, log_scale=True)
+        mesh = ax.collections[0]
+        mesh_data = mesh.get_array()
+
+        assert_array_equal(mesh_data.data, counts.T.flat)
+
+        edges = itertools.product(y_edges[:-1], x_edges[:-1])
+        for i, (y_i, x_i) in enumerate(edges):
+            path = mesh.get_paths()[i]
+            assert path.vertices[0, 0] == 10 ** x_i
+            assert path.vertices[0, 1] == 10 ** y_i
+
+    def test_mesh_thresh(self, long_df):
+
+        hist = Histogram()
+        counts, (x_edges, y_edges) = hist(long_df["x"], long_df["y"])
+
+        thresh = 5
+        ax = histplot(long_df, x="x", y="y", thresh=thresh)
+        mesh = ax.collections[0]
+        mesh_data = mesh.get_array()
+
+        assert_array_equal(mesh_data.data, counts.T.flat)
+        assert_array_equal(mesh_data.mask, (counts <= thresh).T.flat)
+
+    def test_mesh_common_norm(self, long_df):
+
+        stat = "density"
+        ax = histplot(
+            long_df, x="x", y="y", hue="c", common_norm=True, stat=stat,
+        )
+
+        hist = Histogram(stat="density")
+        hist.define_bin_edges(long_df["x"], long_df["y"])
+
+        for i, sub_df in long_df.groupby("c"):
+
+            mesh = ax.collections[i]
+            mesh_data = mesh.get_array()
+
+            density, (x_edges, y_edges) = hist(sub_df["x"], sub_df["y"])
+
+            scale = len(sub_df) / len(long_df)
+            assert_array_equal(mesh_data.data, (density * scale).T.flat)
+
+    def test_mesh_unique_norm(self, long_df):
+
+        stat = "density"
+        ax = histplot(
+            long_df, x="x", y="y", hue="c", common_norm=False, stat=stat,
+        )
+
+        hist = Histogram()
+        hist.define_bin_edges(long_df["x"], long_df["y"])
+
+        for i, sub_df in long_df.groupby("c"):
+
+            sub_hist = Histogram(bins=hist.bin_edges, stat=stat)
+
+            mesh = ax.collections[i]
+            mesh_data = mesh.get_array()
+
+            density, (x_edges, y_edges) = sub_hist(sub_df["x"], sub_df["y"])
+            assert_array_equal(mesh_data.data, density.T.flat)
+
+    def test_mesh_colors(self, long_df):
+
+        color = "r"
+        f, ax = plt.subplots()
+        histplot(
+            long_df, x="x", y="y", color=color,
+        )
+        mesh = ax.collections[0]
+        assert_array_equal(
+            mesh.get_cmap().colors,
+            _DistributionPlotter()._cmap_from_color(color).colors,
+        )
+
+        f, ax = plt.subplots()
+        histplot(
+            long_df, x="x", y="y", hue="c",
+        )
+        colors = color_palette()
+        for i, mesh in enumerate(ax.collections):
+            assert_array_equal(
+                mesh.get_cmap().colors,
+                _DistributionPlotter()._cmap_from_color(colors[i]).colors,
+            )
+
+    def test_color_limits(self, long_df):
+
+        f, (ax1, ax2, ax3) = plt.subplots(3)
+        kws = dict(data=long_df, x="x", y="y")
+        hist = Histogram()
+        counts, _ = hist(long_df["x"], long_df["y"])
+
+        histplot(**kws, ax=ax1)
+        assert ax1.collections[0].get_clim() == (0, counts.max())
+
+        vmax = 10
+        histplot(**kws, vmax=vmax, ax=ax2)
+        counts, _ = hist(long_df["x"], long_df["y"])
+        assert ax2.collections[0].get_clim() == (0, vmax)
+
+        pmax = .8
+        pthresh = .1
+        f = _DistributionPlotter()._quantile_to_level
+
+        histplot(**kws, pmax=pmax, pthresh=pthresh, ax=ax3)
+        counts, _ = hist(long_df["x"], long_df["y"])
+        mesh = ax3.collections[0]
+        assert mesh.get_clim() == (0, f(counts, pmax))
+        assert_array_equal(
+            mesh.get_array().mask,
+            (counts <= f(counts, pthresh)).T.flat,
+        )
+
+    def test_hue_color_limits(self, long_df):
+
+        _, (ax1, ax2, ax3, ax4) = plt.subplots(4)
+        kws = dict(data=long_df, x="x", y="y", hue="c", bins=4)
+
+        hist = Histogram(bins=kws["bins"])
+        hist.define_bin_edges(long_df["x"], long_df["y"])
+        full_counts, _ = hist(long_df["x"], long_df["y"])
+
+        sub_counts = []
+        for _, sub_df in long_df.groupby(kws["hue"]):
+            c, _ = hist(sub_df["x"], sub_df["y"])
+            sub_counts.append(c)
+
+        pmax = .8
+        pthresh = .05
+        f = _DistributionPlotter()._quantile_to_level
+
+        histplot(**kws, common_norm=True, ax=ax1)
+        for i, mesh in enumerate(ax1.collections):
+            assert mesh.get_clim() == (0, full_counts.max())
+
+        histplot(**kws, common_norm=False, ax=ax2)
+        for i, mesh in enumerate(ax2.collections):
+            assert mesh.get_clim() == (0, sub_counts[i].max())
+
+        histplot(**kws, common_norm=True, pmax=pmax, pthresh=pthresh, ax=ax3)
+        for i, mesh in enumerate(ax3.collections):
+            assert mesh.get_clim() == (0, f(full_counts, pmax))
+            assert_array_equal(
+                mesh.get_array().mask,
+                (sub_counts[i] <= f(full_counts, pthresh)).T.flat,
+            )
+
+        histplot(**kws, common_norm=False, pmax=pmax, pthresh=pthresh, ax=ax4)
+        for i, mesh in enumerate(ax4.collections):
+            assert mesh.get_clim() == (0, f(sub_counts[i], pmax))
+            assert_array_equal(
+                mesh.get_array().mask,
+                (sub_counts[i] <= f(sub_counts[i], pthresh)).T.flat,
+            )
+
+    def test_colorbar(self, long_df):
+
+        f, ax = plt.subplots()
+        histplot(long_df, x="x", y="y", cbar=True, ax=ax)
+        assert len(ax.figure.axes) == 2
+
+        f, (ax, cax) = plt.subplots(2)
+        histplot(long_df, x="x", y="y", cbar=True, cbar_ax=cax, ax=ax)
+        assert len(ax.figure.axes) == 2
diff --git a/seaborn/tests/test_statistics.py b/seaborn/tests/test_statistics.py
index 3d5cc4aa19..87a63169cf 100644
--- a/seaborn/tests/test_statistics.py
+++ b/seaborn/tests/test_statistics.py
@@ -6,6 +6,7 @@

 from .._statistics import (
     KDE,
+    Histogram,
 )

@@ -124,3 +125,257 @@ def test_bivariate_cumulative(self, rng):

         assert density[0, 0] == pytest.approx(0, abs=1e-2)
         assert density[-1, -1] == pytest.approx(1, abs=1e-2)
+
+
+class TestHistogram:
+
+    @pytest.fixture
+    def x(self, rng):
+        return rng.normal(0, 1, 100)
+
+    @pytest.fixture
+    def y(self, rng):
+        return rng.normal(0, 5, 100)
+
+    def test_string_bins(self, x):
+
+        h = Histogram(bins="sqrt")
+        edges = h.define_bin_edges(x)
+        assert_array_equal(edges, np.histogram_bin_edges(x, "sqrt"))
+
+    def test_int_bins(self, x):
+
+        n = 24
+        h = Histogram(bins=n)
+        edges = h.define_bin_edges(x)
+        assert len(edges) == n + 1
+
+    def test_array_bins(self, x):
+
+        bins = [-3, -2, 1, 2, 3]
+        h = Histogram(bins=bins)
+        edges = h.define_bin_edges(x)
+        assert_array_equal(edges, bins)
+
+    def test_bivariate_string_bins(self, x, y):
+
+        s1, s2 = "sqrt", "fd"
+
+        h = Histogram(bins=s1)
+        e1, e2 = h.define_bin_edges(x, y)
+        assert_array_equal(e1, np.histogram_bin_edges(x, s1))
+        assert_array_equal(e2, np.histogram_bin_edges(y, s1))
+
+        h = Histogram(bins=(s1, s2))
+        e1, e2 = h.define_bin_edges(x, y)
+        assert_array_equal(e1, np.histogram_bin_edges(x, s1))
+        assert_array_equal(e2, np.histogram_bin_edges(y, s2))
+
+    def test_bivariate_int_bins(self, x, y):
+
+        b1, b2 = 5, 10
+
+        h = Histogram(bins=b1)
+        e1, e2 = h.define_bin_edges(x, y)
+        assert len(e1) == b1 + 1
+        assert len(e2) == b1 + 1
+
+        h = Histogram(bins=(b1, b2))
+        e1, e2 = h.define_bin_edges(x, y)
+        assert len(e1) == b1 + 1
+        assert len(e2) == b2 + 1
+
+    def test_bivariate_array_bins(self, x, y):
+
+        b1 = [-3, -2, 1, 2, 3]
+        b2 = [-5, -2, 3, 6]
+
+        h = Histogram(bins=b1)
+        e1, e2 = h.define_bin_edges(x, y)
+        assert_array_equal(e1, b1)
+        assert_array_equal(e2, b1)
+
+        h = Histogram(bins=(b1, b2))
+        e1, e2 = h.define_bin_edges(x, y)
+        assert_array_equal(e1, b1)
+        assert_array_equal(e2, b2)
+
+    def test_binwidth(self, x):
+
+        binwidth = .5
+        h = Histogram(binwidth=binwidth)
+        edges = h.define_bin_edges(x)
+        assert np.all(np.diff(edges) == binwidth)
+
+    def test_bivariate_binwidth(self, x, y):
+
+        w1, w2 = .5, 1
+
+        h = Histogram(binwidth=w1)
+        e1, e2 = h.define_bin_edges(x, y)
+        assert np.all(np.diff(e1) == w1)
+        assert np.all(np.diff(e2) == w1)
+
+        h = Histogram(binwidth=(w1, w2))
+        e1, e2 = h.define_bin_edges(x, y)
+        assert np.all(np.diff(e1) == w1)
+        assert np.all(np.diff(e2) == w2)
+
+    def test_binrange(self, x):
+
+        binrange = (-4, 4)
+        h = Histogram(binrange=binrange)
+        edges = h.define_bin_edges(x)
+        assert edges.min() == binrange[0]
+        assert edges.max() == binrange[1]
+
+    def test_bivariate_binrange(self, x, y):
+
+        r1, r2 = (-4, 4), (-10, 10)
+
+        h = Histogram(binrange=r1)
+        e1, e2 = h.define_bin_edges(x, y)
+        assert e1.min() == r1[0]
+        assert e1.max() == r1[1]
+        assert e2.min() == r1[0]
+        assert e2.max() == r1[1]
+
+        h = Histogram(binrange=(r1, r2))
+        e1, e2 = h.define_bin_edges(x, y)
+        assert e1.min() == r1[0]
+        assert e1.max() == r1[1]
+        assert e2.min() == r2[0]
+        assert e2.max() == r2[1]
+
+    def test_discrete_bins(self, rng):
+
+        x = rng.binomial(20, .5, 100)
+        h = Histogram(discrete=True)
+        edges = h.define_bin_edges(x)
+        expected_edges = np.arange(x.min(), x.max() + 2) - .5
+        assert_array_equal(edges, expected_edges)
+
+    def test_histogram(self, x):
+
+        h = Histogram()
+        heights, edges = h(x)
+        heights_mpl, edges_mpl = np.histogram(x, bins="auto")
+
+        assert_array_equal(heights, heights_mpl)
+        assert_array_equal(edges, edges_mpl)
+
+    def test_count_stat(self, x):
+
+        h = Histogram(stat="count")
+        heights, _ = h(x)
+        assert heights.sum() == len(x)
+
+    def test_density_stat(self, x):
+
+        h = Histogram(stat="density")
+        heights, edges = h(x)
+        assert (heights * np.diff(edges)).sum() == 1
+
+    def test_probability_stat(self, x):
+
+        h = Histogram(stat="probability")
+        heights, _ = h(x)
+        assert heights.sum() == 1
+
+    def test_frequency_stat(self, x):
+
+        h = Histogram(stat="frequency")
+        heights, edges = h(x)
+        assert (heights * np.diff(edges)).sum() == len(x)
+
+    def test_cumulative_count(self, x):
+
+        h = Histogram(stat="count", cumulative=True)
+        heights, _ = h(x)
+        assert heights[-1] == len(x)
+
+    def test_cumulative_density(self, x):
+
+        h = Histogram(stat="density", cumulative=True)
+        heights, _ = h(x)
+        assert heights[-1] == 1
+
+    def test_cumulative_probability(self, x):
+
+        h = Histogram(stat="probability", cumulative=True)
+        heights, _ = h(x)
+        assert heights[-1] == 1
+
+    def test_cumulative_frequency(self, x):
+
+        h = Histogram(stat="frequency", cumulative=True)
+        heights, _ = h(x)
+        assert heights[-1] == len(x)
+
+    def test_bivariate_histogram(self, x, y):
+
+        h = Histogram()
+        heights, edges = h(x, y)
+        bins_mpl = (
+            np.histogram_bin_edges(x, "auto"),
+            np.histogram_bin_edges(y, "auto"),
+        )
+        heights_mpl, *edges_mpl = np.histogram2d(x, y, bins_mpl)
+        assert_array_equal(heights, heights_mpl)
+        assert_array_equal(edges[0], edges_mpl[0])
+        assert_array_equal(edges[1], edges_mpl[1])
+
+    def test_bivariate_count_stat(self, x, y):
+
+        h = Histogram(stat="count")
+        heights, _ = h(x, y)
+        assert heights.sum() == len(x)
+
+    def test_bivariate_density_stat(self, x, y):
+
+        h = Histogram(stat="density")
+        heights, (edges_x, edges_y) = h(x, y)
+        areas = np.outer(np.diff(edges_x), np.diff(edges_y))
+        assert (heights * areas).sum() == pytest.approx(1)
+
+    def test_bivariate_probability_stat(self, x, y):
+
+        h = Histogram(stat="probability")
+        heights, _ = h(x, y)
+        assert heights.sum() == 1
+
+    def test_bivariate_frequency_stat(self, x, y):
+
+        h = Histogram(stat="frequency")
+        heights, (x_edges, y_edges) = h(x, y)
+        area = np.outer(np.diff(x_edges), np.diff(y_edges))
+        assert (heights * area).sum() == len(x)
+
+    def test_bivariate_cumulative_count(self, x, y):
+
+        h = Histogram(stat="count", cumulative=True)
+        heights, _ = h(x, y)
+        assert heights[-1, -1] == len(x)
+
+    def test_bivariate_cumulative_density(self, x, y):
+
+        h = Histogram(stat="density", cumulative=True)
+        heights, _ = h(x, y)
+        assert heights[-1, -1] == pytest.approx(1)
+
+    def test_bivariate_cumulative_frequency(self, x, y):
+
+        h = Histogram(stat="frequency", cumulative=True)
+        heights, _ = h(x, y)
+        assert heights[-1, -1] == len(x)
+
+    def test_bivariate_cumulative_probability(self, x, y):
+
+        h = Histogram(stat="probability", cumulative=True)
+        heights, _ = h(x, y)
+        assert heights[-1, -1] == pytest.approx(1)
+
+    def test_bad_stat(self):
+
+        with pytest.raises(ValueError):
+            Histogram(stat="invalid")

--- /dev/null
+++ b/doc/docstrings/histplot.ipynb
@@ -0,0 +1,112 @@
+Plot a univariate distribution along the x axis:
+import seaborn as sns; sns.set(style="white")
+
+penguins = sns.load_dataset("penguins")
+
+sns.histplot(data=penguins, x="flipper_length_mm")
+Flip the plot by assigning the data variable to the y axis:
+sns.histplot(data=penguins, y="flipper_length_mm")
+Check how well the histogram represents the data by specifying a different bin width:
+sns.histplot(data=penguins, x="flipper_length_mm", binwidth=3)
+You can also define the total number of bins to use:
+sns.histplot(data=penguins, x="flipper_length_mm", bins=30)
+Add a kernel density estimate to smooth the histogram, providing complementary information about the shape of the distribution:
+sns.histplot(data=penguins, x="flipper_length_mm", kde=True)
+If neither `x` nor `y` is assigned, the dataset is treated as wide-form, and a histogram is drawn for each numeric column:
+iris = sns.load_dataset("iris")
+
+sns.histplot(data=iris)
+You can also draw multiple histograms from a long-form dataset with hue mapping:
+sns.histplot(data=penguins, x="flipper_length_mm", hue="species")
+The default approach to plotting multiple distributions is to "layer" them, but you can also "stack" them:
+sns.histplot(data=penguins, x="flipper_length_mm", hue="species", multiple="stack")
+Overlapping bars can be hard to visually resolve. A different approach would be to draw a step function:
+sns.histplot(penguins, x="flipper_length_mm", hue="species", element="step")
+You can move even farther away from bars by drawing a polygon with vertices in the center of each bin. This may make it easier to see the shape of the distribution, but use with caution: it will be less obvious to your audience that they are looking at a histogram:
+sns.histplot(penguins, x="flipper_length_mm", hue="species", element="poly")
+To compare the distribution of subsets that differ substantially in size, use indepdendent density normalization:
+sns.histplot(
+
+    penguins, x="culmen_length_mm", hue="island", element="step",
+
+    stat="density", common_norm=False,
+
+)
+It's also possible to normalize so that each bar's height shows a probability, which make more sense for discrete variables:
+tips = sns.load_dataset("tips")
+
+sns.histplot(data=tips, x="size", stat="probability", discrete=True)
+You can even draw a histogram over categorical variables (although this is an experimental feature):
+sns.histplot(data=tips, x="day", shrink=.8)
+When using a ``hue`` semantic with discrete data, it can make sense to "dodge" the levels:
+sns.histplot(data=tips, x="day", hue="sex", multiple="dodge", shrink=.8)
+For heavily skewed distributions, it's better to define the bins in log space. Compare:
+planets = sns.load_dataset("planets")
+
+sns.histplot(data=planets, x="distance")
+To the log-scale version:
+sns.histplot(data=planets, x="distance", log_scale=True)
+There are also a number of options for how the histogram appears. You can show unfilled bars:
+sns.histplot(data=planets, x="distance", log_scale=True, fill=False)
+Or an unfilled step function:
+sns.histplot(data=planets, x="distance", log_scale=True, element="step", fill=False)
+Step functions, esepcially when unfilled, make it easy to compare cumulative histograms:
+sns.histplot(
+
+    data=planets, x="distance", hue="method",
+
+    hue_order=["Radial Velocity", "Transit"],
+
+    log_scale=True, element="step", fill=False,
+
+    cumulative=True, stat="density", common_norm=False,
+
+)
+When both ``x`` and ``y`` are assigned, a bivariate histogram is computed and shown as a heatmap:
+sns.histplot(penguins, x="culmen_depth_mm", y="body_mass_g")
+It's possible to assign a ``hue`` variable too, although this will not work well if data from the different levels have substantial overlap:
+sns.histplot(penguins, x="culmen_depth_mm", y="body_mass_g", hue="species")
+Multiple color maps can make sense when one of the variables is discrete:
+sns.histplot(
+
+    penguins, x="culmen_depth_mm", y="species", hue="species", legend=False
+
+)
+The bivariate histogram accepts all of the same options for computation as its univariate counterpart, using tuples to parametrize ``x`` and ``y`` independently:
+sns.histplot(
+
+    planets, x="year", y="distance",
+
+    bins=30, discrete=(True, False), log_scale=(False, True),
+
+)
+The default behavior makes cells with no observations transparent, although this can be disabled:
+sns.histplot(
+
+    planets, x="year", y="distance",
+
+    bins=30, discrete=(True, False), log_scale=(False, True),
+
+    thresh=None,
+
+)
+It's also possible to set the threshold and colormap saturation point in terms of the proportion of cumulative counts:
+sns.histplot(
+
+    planets, x="year", y="distance",
+
+    bins=30, discrete=(True, False), log_scale=(False, True),
+
+    pthresh=.05, pmax=.9,
+
+)
+To annotate the colormap, add a colorbar:
+sns.histplot(
+
+    planets, x="year", y="distance",
+
+    bins=30, discrete=(True, False), log_scale=(False, True),
+
+    cbar=True, cbar_kws=dict(shrink=.75),
+
+)

diff --git a/doc/Makefile b/doc/Makefile
index 1dfec5de..8ed9484c 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -44,13 +44,11 @@ clean:
 	-rm -rf $(BUILDDIR)/*
 	-rm -rf examples/*
 	-rm -rf example_thumbs/*
-	-rm -rf tutorial/*_files/
-	-rm -rf tutorial/*.rst
-	-rm -rf docstrings/*_files/
-	-rm -rf docstrings/*.rst
 	-rm -rf generated/*
 	-rm -rf introduction_files/*
 	-rm introduction.rst
+	-make -C docstrings clean
+	-make -C tutorial clean

 .PHONY: tutorials
 tutorials:

diff --git a/doc/api.rst b/doc/api.rst
index 520d0eda..81cde3ea 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -44,6 +44,7 @@ Distribution plots
     :toctree: generated/

     distplot
+    histplot
     kdeplot
     rugplot

diff --git a/doc/docstrings/Makefile b/doc/docstrings/Makefile
index 57045bfc..dd04013d 100644
--- a/doc/docstrings/Makefile
+++ b/doc/docstrings/Makefile
@@ -8,3 +8,8 @@ docstrings: ${rst_files}
 	@if [ -f ../generated/seaborn.$*.rst ]; then \
 	    touch ../generated/seaborn.$*.rst; \
 	 fi
+
+clean:
+	rm -rf *.rst
+	rm -rf *_files/
+	rm -rf .ipynb_checkpoints/

diff --git a/doc/docstrings/histplot.ipynb b/doc/docstrings/histplot.ipynb
new file mode 100644
index 00000000..2efde821
--- /dev/null
+++ b/doc/docstrings/histplot.ipynb
@@ -0,0 +1,471 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Plot a univariate distribution along the x axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import seaborn as sns; sns.set(style=\"white\")\n",
+    "penguins = sns.load_dataset(\"penguins\")\n",
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Flip the plot by assigning the data variable to the y axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, y=\"flipper_length_mm\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Check how well the histogram represents the data by specifying a different bin width:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", binwidth=3)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also define the total number of bins to use:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", bins=30)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Add a kernel density estimate to smooth the histogram, providing complementary information about the shape of the distribution:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", kde=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If neither `x` nor `y` is assigned, the dataset is treated as wide-form, and a histogram is drawn for each numeric column:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "iris = sns.load_dataset(\"iris\")\n",
+    "sns.histplot(data=iris)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also draw multiple histograms from a long-form dataset with hue mapping:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default approach to plotting multiple distributions is to \"layer\" them, but you can also \"stack\" them:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=penguins, x=\"flipper_length_mm\", hue=\"species\", multiple=\"stack\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Overlapping bars can be hard to visually resolve. A different approach would be to draw a step function:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"step\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can move even farther away from bars by drawing a polygon with vertices in the center of each bin. This may make it easier to see the shape of the distribution, but use with caution: it will be less obvious to your audience that they are looking at a histogram:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"flipper_length_mm\", hue=\"species\", element=\"poly\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To compare the distribution of subsets that differ substantially in size, use indepdendent density normalization:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    penguins, x=\"culmen_length_mm\", hue=\"island\", element=\"step\",\n",
+    "    stat=\"density\", common_norm=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's also possible to normalize so that each bar's height shows a probability, which make more sense for discrete variables:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tips = sns.load_dataset(\"tips\")\n",
+    "sns.histplot(data=tips, x=\"size\", stat=\"probability\", discrete=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can even draw a histogram over categorical variables (although this is an experimental feature):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=tips, x=\"day\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "When using a ``hue`` semantic with discrete data, it can make sense to \"dodge\" the levels:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=tips, x=\"day\", hue=\"sex\", multiple=\"dodge\", shrink=.8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For heavily skewed distributions, it's better to define the bins in log space. Compare:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "planets = sns.load_dataset(\"planets\")\n",
+    "sns.histplot(data=planets, x=\"distance\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To the log-scale version:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "There are also a number of options for how the histogram appears. You can show unfilled bars:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True, fill=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Or an unfilled step function:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(data=planets, x=\"distance\", log_scale=True, element=\"step\", fill=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Step functions, esepcially when unfilled, make it easy to compare cumulative histograms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    data=planets, x=\"distance\", hue=\"method\",\n",
+    "    hue_order=[\"Radial Velocity\", \"Transit\"],\n",
+    "    log_scale=True, element=\"step\", fill=False,\n",
+    "    cumulative=True, stat=\"density\", common_norm=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "When both ``x`` and ``y`` are assigned, a bivariate histogram is computed and shown as a heatmap:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"culmen_depth_mm\", y=\"body_mass_g\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's possible to assign a ``hue`` variable too, although this will not work well if data from the different levels have substantial overlap:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(penguins, x=\"culmen_depth_mm\", y=\"body_mass_g\", hue=\"species\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Multiple color maps can make sense when one of the variables is discrete:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    penguins, x=\"culmen_depth_mm\", y=\"species\", hue=\"species\", legend=False\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The bivariate histogram accepts all of the same options for computation as its univariate counterpart, using tuples to parametrize ``x`` and ``y`` independently:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The default behavior makes cells with no observations transparent, although this can be disabled: "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    thresh=None,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "It's also possible to set the threshold and colormap saturation point in terms of the proportion of cumulative counts:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    pthresh=.05, pmax=.9,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To annotate the colormap, add a colorbar:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sns.histplot(\n",
+    "    planets, x=\"year\", y=\"distance\",\n",
+    "    bins=30, discrete=(True, False), log_scale=(False, True),\n",
+    "    cbar=True, cbar_kws=dict(shrink=.75),\n",
+    ")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-refactor (py38)",
+   "language": "python",
+   "name": "seaborn-refactor"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}

diff --git a/doc/releases/v0.11.0.txt b/doc/releases/v0.11.0.txt
index 0fde4318..f344b192 100644
--- a/doc/releases/v0.11.0.txt
+++ b/doc/releases/v0.11.0.txt
@@ -9,38 +9,42 @@ v0.11.0 (Unreleased)
 Modernization of distribution functions
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

-Completely overhauled :func:`kdeplot` and :func:`rugplot`. Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+First, a new function, :func:`histplot` has been added. :func:`histplot` draws univariate or bivariate histograms with a number of features, including:

-The new functions now share a common API with the rest of seaborn, can show conditional distributions by mapping a third variable with a ``hue`` semantic, have been improved in numerous other ways. The `github pull request (GH2104) <https://github.com/mwaskom/seaborn/pull/2104>`_ has a longer explanation of the changes and the motivation behind them.
+- mapping multiple distributions with a ``hue`` semantic
+- normalization to show density, probability, or frequency statistics
+- flexible parameterization of bin size, including proper bins for discrete variables
+- adding a KDE fit to show a smoothed distribution over all bin statistics
+- experimental support for histograms over categorical and datetime variables.
+
+Second, the existing functions :func:`kdeplot` and :func:`rugplot` have been completely overhauled. Two of the oldest functions in the library, these lacked aspects of the otherwise-common seaborn API, such as the ability to assign variables by name from a ``data`` object; they had no capacity for semantic mapping; and they had numerous other inconsistencies and smaller issues.
+
+The overhauled functions now share a common API with the rest of seaborn, they can show conditional distributions by mapping a third variable with a ``hue`` semantic, and have been improved in numerous other ways. The `github pull request (GH2104) <https://github.com/mwaskom/seaborn/pull/2104>`_ has a longer explanation of the changes and the motivation behind them.

 This is a necessarily API-breaking change. The parameter names for the positional variables
 are now ``x`` and ``y``, and the old names have been deprecated. Efforts were made to handle and warn when using the deprecated API, but it is strongly suggested to check your plots carefully.

-Additionally, the optional statsmodels-based computation of the KDE has been removed. Because there were some inconsistencies between the way different parameters (specifically, ``bw``, ``clip``, and ``cut``) were implemented by each backend, this may cause plots to look different after an upgrade. Support for using non-Gaussian kernels, which was available only in the statsmodels backend, has been removed. Support for drawing a cumulative distribution function, which was not previously available with the scipy backend, has been added, including for bivariate densities.
+Additionally, the optional statsmodels-based computation of the KDE has been removed. Because there were some inconsistencies between the way different parameters (specifically, ``bw``, ``clip``, and ``cut``) were implemented by each backend, this may cause plots to look different after an upgrade when non-default values were set for these parameters. Support for using non-Gaussian kernels, which was available only in the statsmodels backend, has been removed. Support for drawing a cumulative distribution function, which was not previously available with the scipy backend, has been added, including for bivariate densities.

 Other new features include:

-- Several options for representing multiple densities from subsets of data defined by semantic variables (with the new ``multiple`` and ``common_norm`` parameters)
-- Weighted KDE (using the new ``weights=`` parameter)
-- Better control over the smoothing bandwidth (using the new ``bw_adjust`` parameter)
-- More meaningful and configurable selection of the contours that represent a bivariate density (using the new ``thresh`` and ``levels`` parameters)
-- Log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
-- "Bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)
+- several options for representing multiple densities from subsets of data defined by semantic variables (with the new ``multiple`` and ``common_norm`` parameters)
+- weighted KDE (using the new ``weights=`` parameter)
+- better control over the smoothing bandwidth (using the new ``bw_adjust`` parameter)
+- more meaningful and configurable selection of the contours that represent a bivariate density (using the new ``thresh`` and ``levels`` parameters)
+- log-space density estimation (using the new ``log_scale`` parameter, or by scaling the data axis before plotting)
+- "bivariate" rug plots with a single function call (by assigning both ``x`` and ``y``)

 GH2060, GH2104

 Other
 ~~~~~

-- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_. GH2075
-
-- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. GH2086, GH2118, GH803
-
-- The visual appearance of :func:`boxenplot` now more resembles that of :func:`boxplot`.
+- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.  GH2084

-- Fixed a bug where elements of :func:`boxenplot` were sometimes not entirely visible. GH2085
+- Plots with a ``style`` semantic can now generate an infinite number of unique dashes and/or markers by default. Prevously, an error would be raised if the ``style`` variable had more levels than could be mapped using the default lists. The existing defaults were slightly modified as part of this change; if you need to exactly reproduce plots from earlier versions, refer to the `old defaults <https://github.com/mwaskom/seaborn/blob/v0.10.1/seaborn/relational.py#L24>`_. GH2075

-- Following its deprecation, `lvplot` has finally been removed from the codebase.
+- Fixed a few issues with :func:`boxenplot` regarding the way the number of boxes are calculated. `k_depth="tukey"` is now the default boxes calculation method as the previous default (`"proportion"`) produces too many boxes when datasets are small. Added the option to specify the desired number of boxes as a scalar (e.g. `k_depth=6`) or just plot boxes that will cover most of the data points (with `k_depth="full"`). Added a new parameter `trust_alpha` to control the number of boxes when `k_depth="trustworthy"`. Additionally, the visual appearance of :func:`boxenplot` now more closely resembles :func:`boxplot`, and thin boxes will remain visible when the edges are white. Finally, the ``lvplot`` function (the previously-deprecated name for :func:`boxenplot`) has been removed. GH2086

 - Added a ``tight_layout`` method to :class:`FacetGrid` and :class:`PairGrid`, which runs the :func:`matplotlib.pyplot.tight_layout` algorithm without interference from the external legend. GH2073

@@ -48,7 +52,7 @@ Other

 - Adapted to a change in matplotlib that prevented passing vectors of literal values to ``c`` and ``s`` in :func:`scatterplot`.  GH2079

-- Added an explicit warning in :func:`swarmplot` when more than 2% of the points are overlap in the "gutters" of the swarm. GH2045
+- Added an explicit warning in :func:`swarmplot` when more than 2% of the points overlap in the "gutters" of the swarm. GH2045

 - Added the ``axes_dict`` attribute to :class:`FacetGrid` for named access to the component axes.  GH2046

@@ -58,8 +62,6 @@ Other

 - Fixed a bug where :func:`lineplot` did not pass the ``linestyle`` parameter down to matplotlib. GH2095

-- Changed how functions that use different representations for numeric and categorical data handle vectors with an `object` data type. Previously, data was considered numeric if it could be coerced to a float representation without error. Now, object-typed vectors are considered numeric only when their contents are themselves numeric. As a consequence, numbers that are encoded as strings will now be treated as categorical data.  GH2084
-
 - Improved the error messages produced when categorical plots process the orientation parameter.

 - Deprecated the ``axlabel`` function; use ``ax.set(xlabel=, ylabel=)`` instead.

diff --git a/doc/tutorial/Makefile b/doc/tutorial/Makefile
index c8ca64d9..fda6b816 100644
--- a/doc/tutorial/Makefile
+++ b/doc/tutorial/Makefile
@@ -4,3 +4,8 @@ tutorial: ${rst_files}

 %.rst: %.ipynb
 	../tools/nb_to_doc.py $*.ipynb
+
+clean:
+	rm -rf *.rst
+	rm -rf *_files/
+	rm -rf .ipynb_checkpoints/

diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst
index 5be2ab30..08eb9ce8 100644
--- a/doc/whatsnew.rst
+++ b/doc/whatsnew.rst
@@ -11,6 +11,8 @@ This page contains information about what has changed in each new version of ``s

    <div class="col-md-9">

+.. include:: releases/v0.11.0.txt
+
 .. include:: releases/v0.10.1.txt

 .. include:: releases/v0.10.0.txt

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'yaxis'}, {'type': 'function', 'name': '_cmap_from_color'}, {'type': 'field', 'name': 'dropna'}, {'type': 'field', 'name': 'get_scale'}, {'type': 'field', 'name': 'line_kws'}, {'type': 'field', 'name': 'mesh'}, {'type': 'field', 'name': 'area'}, {'type': 'field', 'name': 'comp_data'}, {'type': 'field', 'name': '_cmap_from_color'}, {'type': 'field', 'name': 'figsize'}, {'type': 'field', 'name': 'outer'}, {'type': 'function', 'name': 'comp_data'}, {'type': 'field', 'name': 'allowed_types'}, {'type': 'field', 'name': '_attach'}, {'type': 'field', 'name': 'define_bin_edges'}, {'type': 'function', 'name': '_attach'}, {'type': 'field', 'name': 'xaxis'}, {'type': 'field', 'name': 'binrange'}, {'type': 'field', 'name': '_quantile_to_level'}, {'type': 'file', 'name': 'examples/multi_bivariate_plot.py'}, {'type': 'class', 'name': 'Histogram'}, {'type': 'field', 'name': 'define_bin_edges'}, {'type': 'field', 'name': 'start'}, {'type': 'field', 'name': 'comp_data'}, {'type': 'field', 'name': 'Histogram'}, {'type': 'field', 'name': 'histogram_bin_edges'}, {'type': 'field', 'name': 'figsize'}, {'type': 'field', 'name': 'log10'}, {'type': 'field', 'name': 'bin_edges'}, {'type': 'field', 'name': 'allowed_types'}, {'type': 'field', 'name': 'arange'}, {'type': 'function', 'name': 'define_bin_edges'}, {'type': 'file', 'name': 'examples/stacked_histogram.py'}, {'type': 'field', 'name': 'y_edges'}, {'type': 'field', 'name': 'x_edges'}, {'type': 'field', 'name': 'xaxis'}, {'type': 'field', 'name': 'common_bins'}, {'type': 'field', 'name': 'heights'}, {'type': 'field', 'name': 'stop'}, {'type': 'field', 'name': 'histogram2d'}, {'type': 'field', 'name': 'convert_units'}, {'type': 'function', 'name': '_quantile_to_level'}, {'type': 'field', 'name': 'vmax'}, {'type': 'field', 'name': 'binrange'}]