diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index 4a12cddbbb..8a0508c808 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -10,6 +10,11 @@ v0.12.0 (Unreleased)

   - In :func:`stripplot`, a "strip" with a single observation will be plotted without jitter (:pr:`2413`)

+  - In :func:`swarmplot`, the points are now swarmed at draw time, meaning that the plot will adapt to further changes in axes scaling or tweaks to the plot layout (:pr:`2443`).
+
+  - In :func:`swarmplot`, the order of the points in each swarm now matches the order in the original dataset; previously they were sorted. This affects only the underlying data stored in the matplotlib artist, not the visual representation (:pr:`2443`).
+
+
 - Made `scipy` an optional dependency and added `pip install seaborn[all]` as a method for ensuring the availability of compatible `scipy` and `statsmodels` libraries at install time. This has a few minor implications for existing code, which are explained in the Github pull request (:pr:`2398`).

 - Following `NEP29 <https://numpy.org/neps/nep-0029-deprecation_policy.html>`_, dropped support for Python 3.6 and bumped the minimally-supported versions of the library dependencies.
diff --git a/seaborn/categorical.py b/seaborn/categorical.py
index 57066c7285..235b357d01 100644
--- a/seaborn/categorical.py
+++ b/seaborn/categorical.py
@@ -1368,161 +1368,6 @@ def __init__(self, x, y, hue, data, order, hue_order,
         self.dodge = dodge
         self.width = .8

-    def could_overlap(self, xy_i, swarm, d):
-        """Return a list of all swarm points that could overlap with target.
-
-        Assumes that swarm is a sorted list of all points below xy_i.
-        """
-        _, y_i = xy_i
-        neighbors = []
-        for xy_j in reversed(swarm):
-            _, y_j = xy_j
-            if (y_i - y_j) < d:
-                neighbors.append(xy_j)
-            else:
-                break
-        return np.array(list(reversed(neighbors)))
-
-    def position_candidates(self, xy_i, neighbors, d):
-        """Return a list of (x, y) coordinates that might be valid."""
-        candidates = [xy_i]
-        x_i, y_i = xy_i
-        left_first = True
-        for x_j, y_j in neighbors:
-            dy = y_i - y_j
-            dx = np.sqrt(max(d ** 2 - dy ** 2, 0)) * 1.05
-            cl, cr = (x_j - dx, y_i), (x_j + dx, y_i)
-            if left_first:
-                new_candidates = [cl, cr]
-            else:
-                new_candidates = [cr, cl]
-            candidates.extend(new_candidates)
-            left_first = not left_first
-        return np.array(candidates)
-
-    def first_non_overlapping_candidate(self, candidates, neighbors, d):
-        """Remove candidates from the list if they overlap with the swarm."""
-
-        # IF we have no neighbours, all candidates are good.
-        if len(neighbors) == 0:
-            return candidates[0]
-
-        neighbors_x = neighbors[:, 0]
-        neighbors_y = neighbors[:, 1]
-
-        d_square = d ** 2
-
-        for xy_i in candidates:
-            x_i, y_i = xy_i
-
-            dx = neighbors_x - x_i
-            dy = neighbors_y - y_i
-
-            sq_distances = np.power(dx, 2.0) + np.power(dy, 2.0)
-
-            # good candidate does not overlap any of neighbors
-            # which means that squared distance between candidate
-            # and any of the neighbours has to be at least
-            # square of the diameter
-            good_candidate = np.all(sq_distances >= d_square)
-
-            if good_candidate:
-                return xy_i
-
-        # If `position_candidates` works well
-        # this should never happen
-        raise Exception('No non-overlapping candidates found. '
-                        'This should not happen.')
-
-    def beeswarm(self, orig_xy, d):
-        """Adjust x position of points to avoid overlaps."""
-        # In this method, ``x`` is always the categorical axis
-        # Center of the swarm, in point coordinates
-        midline = orig_xy[0, 0]
-
-        # Start the swarm with the first point
-        swarm = [orig_xy[0]]
-
-        # Loop over the remaining points
-        for xy_i in orig_xy[1:]:
-
-            # Find the points in the swarm that could possibly
-            # overlap with the point we are currently placing
-            neighbors = self.could_overlap(xy_i, swarm, d)
-
-            # Find positions that would be valid individually
-            # with respect to each of the swarm neighbors
-            candidates = self.position_candidates(xy_i, neighbors, d)
-
-            # Sort candidates by their centrality
-            offsets = np.abs(candidates[:, 0] - midline)
-            candidates = candidates[np.argsort(offsets)]
-
-            # Find the first candidate that does not overlap any neighbours
-            new_xy_i = self.first_non_overlapping_candidate(candidates,
-                                                            neighbors, d)
-
-            # Place it into the swarm
-            swarm.append(new_xy_i)
-
-        return np.array(swarm)
-
-    def add_gutters(self, points, center, width):
-        """Stop points from extending beyond their territory."""
-        half_width = width / 2
-        low_gutter = center - half_width
-        off_low = points < low_gutter
-        if off_low.any():
-            points[off_low] = low_gutter
-        high_gutter = center + half_width
-        off_high = points > high_gutter
-        if off_high.any():
-            points[off_high] = high_gutter
-
-        gutter_prop = (off_high + off_low).sum() / len(points)
-        if gutter_prop > .05:
-            msg = (
-                "{:.1%} of the points cannot be placed; you may want "
-                "to decrease the size of the markers or use stripplot."
-            ).format(gutter_prop)
-            warnings.warn(msg, UserWarning)
-
-        return points
-
-    def swarm_points(self, ax, points, center, width, s, **kws):
-        """Find new positions on the categorical axis for each point."""
-        # Convert from point size (area) to diameter
-        default_lw = mpl.rcParams["patch.linewidth"]
-        lw = kws.get("linewidth", kws.get("lw", default_lw))
-        dpi = ax.figure.dpi
-        d = (np.sqrt(s) + lw) * (dpi / 72)
-
-        # Transform the data coordinates to point coordinates.
-        # We'll figure out the swarm positions in the latter
-        # and then convert back to data coordinates and replot
-        orig_xy = ax.transData.transform(points.get_offsets())
-
-        # Order the variables so that x is the categorical axis
-        if self.orient == "h":
-            orig_xy = orig_xy[:, [1, 0]]
-
-        # Do the beeswarm in point coordinates
-        new_xy = self.beeswarm(orig_xy, d)
-
-        # Transform the point coordinates back to data coordinates
-        if self.orient == "h":
-            new_xy = new_xy[:, [1, 0]]
-        new_x, new_y = ax.transData.inverted().transform(new_xy).T
-
-        # Add gutters
-        if self.orient == "v":
-            self.add_gutters(new_x, center, width)
-        else:
-            self.add_gutters(new_y, center, width)
-
-        # Reposition the points so they do not overlap
-        points.set_offsets(np.c_[new_x, new_y])
-
     def draw_swarmplot(self, ax, kws):
         """Plot the data."""
         s = kws.pop("s")
@@ -1532,12 +1377,6 @@ def draw_swarmplot(self, ax, kws):

         palette = np.asarray(self.colors)

-        # Set the categorical axes limits here for the swarm math
-        if self.orient == "v":
-            ax.set_xlim(-.5, len(self.plot_data) - .5)
-        else:
-            ax.set_ylim(-.5, len(self.plot_data) - .5)
-
         # Plot each swarm
         for i, group_data in enumerate(self.plot_data):

@@ -1548,19 +1387,11 @@ def draw_swarmplot(self, ax, kws):
                 if self.hue_names is None:
                     hue_mask = np.ones(group_data.size, bool)
                 else:
-                    hue_mask = np.array([h in self.hue_names
-                                         for h in self.plot_hues[i]], bool)
-                    # Broken on older numpys
-                    # hue_mask = np.in1d(self.plot_hues[i], self.hue_names)
+                    hue_mask = np.in1d(self.plot_hues[i], self.hue_names)

                 swarm_data = np.asarray(group_data[hue_mask])
                 point_colors = np.asarray(self.point_colors[i][hue_mask])

-                # Sort the points for the beeswarm algorithm
-                sorter = np.argsort(swarm_data)
-                swarm_data = swarm_data[sorter]
-                point_colors = point_colors[sorter]
-
                 # Plot the points in centered positions
                 cat_pos = np.ones(swarm_data.size) * i
                 kws.update(c=palette[point_colors])
@@ -1581,11 +1412,6 @@ def draw_swarmplot(self, ax, kws):
                     swarm_data = np.asarray(group_data[hue_mask])
                     point_colors = np.asarray(self.point_colors[i][hue_mask])

-                    # Sort the points for the beeswarm algorithm
-                    sorter = np.argsort(swarm_data)
-                    swarm_data = swarm_data[sorter]
-                    point_colors = point_colors[sorter]
-
                     # Plot the points in centered positions
                     center = i + offsets[j]
                     cat_pos = np.ones(swarm_data.size) * center
@@ -1598,14 +1424,16 @@ def draw_swarmplot(self, ax, kws):
                     centers.append(center)
                     swarms.append(points)

-        # Autoscale the valus axis to set the data/axes transforms properly
-        ax.autoscale_view(scalex=self.orient == "h", scaley=self.orient == "v")
-
-        # Update the position of each point on the categorical axis
-        # Do this after plotting so that the numerical axis limits are correct
+        # Monkey-patch the scatter artists so swarming will occur at draw time
+        # This ensures that the swarm will always use the most recent data transform
+        # and will be robust to changes in axis limits, figure layout. etc.
+        beeswarm = Beeswarm(width=width, orient=self.orient)
         for center, swarm in zip(centers, swarms):
             if swarm.get_offsets().size:
-                self.swarm_points(ax, swarm, center, width, s, **kws)
+                def draw(points, renderer, *, center=center):
+                    beeswarm(points, center)
+                    super(points.__class__, points).draw(renderer)
+                swarm.draw = draw.__get__(swarm)

     def plot(self, ax, kws):
         """Make the full plot."""
@@ -4253,3 +4081,188 @@ def catplot(
         <seaborn.axisgrid.FacetGrid object at 0x...>

     """).format(**_categorical_docs)
+
+
+class Beeswarm:
+    """Modifies a scatterplot artist to show a beeswarm plot."""
+    def __init__(self, orient="v", width=0.8, warn_gutter_prop=.05):
+
+        self.orient = orient
+        self.width = width
+        self.warn_gutter_prop = warn_gutter_prop
+
+    def __call__(self, points, center):
+        """Swarm `points`, a PathCollection, around the `center` position."""
+        # Convert from point size (area) to diameter
+        ax = points.axes
+        dpi = ax.figure.dpi
+
+        # Get the original positions of the points
+        orig_xy_data = points.get_offsets()
+
+        # Reset the categorical positions to the center line
+        cat_idx = 1 if self.orient == "h" else 0
+        orig_xy_data[:, cat_idx] = center
+
+        # Transform the data coordinates to point coordinates.
+        # We'll figure out the swarm positions in the latter
+        # and then convert back to data coordinates and replot
+        orig_x_data, orig_y_data = orig_xy_data.T
+        orig_xy = ax.transData.transform(orig_xy_data)
+
+        # Order the variables so that x is the categorical axis
+        if self.orient == "h":
+            orig_xy = orig_xy[:, [1, 0]]
+
+        # Add a column with each point's radius
+        sizes = points.get_sizes()
+        if sizes.size == 1:
+            sizes = np.repeat(sizes, orig_xy.shape[0])
+        edge = points.get_linewidth().item()
+        radii = (np.sqrt(sizes) + edge) / 2 * (dpi / 72)
+        orig_xy = np.c_[orig_xy, radii]
+
+        # Sort along the value axis to facilitate the beeswarm
+        sorter = np.argsort(orig_xy[:, 1])
+        orig_xyr = orig_xy[sorter]
+
+        # Adjust points along the categorical axis to prevent overlaps
+        new_xyr = np.empty_like(orig_xyr)
+        new_xyr[sorter] = self.beeswarm(orig_xyr)
+
+        # Transform the point coordinates back to data coordinates
+        if self.orient == "h":
+            new_xy = new_xyr[:, [1, 0]]
+        else:
+            new_xy = new_xyr[:, :2]
+        new_x_data, new_y_data = ax.transData.inverted().transform(new_xy).T
+
+        # Add gutters
+        if self.orient == "h":
+            self.add_gutters(new_y_data, center)
+        else:
+            self.add_gutters(new_x_data, center)
+
+        # Reposition the points so they do not overlap
+        if self.orient == "h":
+            points.set_offsets(np.c_[orig_x_data, new_y_data])
+        else:
+            points.set_offsets(np.c_[new_x_data, orig_y_data])
+
+    def beeswarm(self, orig_xyr):
+        """Adjust x position of points to avoid overlaps."""
+        # In this method, `x` is always the categorical axis
+        # Center of the swarm, in point coordinates
+        midline = orig_xyr[0, 0]
+
+        # Start the swarm with the first point
+        swarm = np.atleast_2d(orig_xyr[0])
+
+        # Loop over the remaining points
+        for xyr_i in orig_xyr[1:]:
+
+            # Find the points in the swarm that could possibly
+            # overlap with the point we are currently placing
+            neighbors = self.could_overlap(xyr_i, swarm)
+
+            # Find positions that would be valid individually
+            # with respect to each of the swarm neighbors
+            candidates = self.position_candidates(xyr_i, neighbors)
+
+            # Sort candidates by their centrality
+            offsets = np.abs(candidates[:, 0] - midline)
+            candidates = candidates[np.argsort(offsets)]
+
+            # Find the first candidate that does not overlap any neighbors
+            new_xyr_i = self.first_non_overlapping_candidate(candidates, neighbors)
+
+            # Place it into the swarm
+            swarm = np.vstack([swarm, new_xyr_i])
+
+        return swarm
+
+    def could_overlap(self, xyr_i, swarm):
+        """Return a list of all swarm points that could overlap with target."""
+        # Because we work backwards through the swarm and can short-circuit,
+        # the for-loop is faster than vectorization
+        _, y_i, r_i = xyr_i
+        neighbors = []
+        for xyr_j in reversed(swarm):
+            _, y_j, r_j = xyr_j
+            if (y_i - y_j) < (r_i + r_j):
+                neighbors.append(xyr_j)
+            else:
+                break
+        return np.array(neighbors)[::-1]
+
+    def position_candidates(self, xyr_i, neighbors):
+        """Return a list of coordinates that might be valid by adjusting x."""
+        candidates = [xyr_i]
+        x_i, y_i, r_i = xyr_i
+        left_first = True
+        for x_j, y_j, r_j in neighbors:
+            dy = y_i - y_j
+            dx = np.sqrt(max((r_i + r_j) ** 2 - dy ** 2, 0)) * 1.05
+            cl, cr = (x_j - dx, y_i, r_i), (x_j + dx, y_i, r_i)
+            if left_first:
+                new_candidates = [cl, cr]
+            else:
+                new_candidates = [cr, cl]
+            candidates.extend(new_candidates)
+            left_first = not left_first
+        return np.array(candidates)
+
+    def first_non_overlapping_candidate(self, candidates, neighbors):
+        """Find the first candidate that does not overlap with the swarm."""
+
+        # If we have no neighbors, all candidates are good.
+        if len(neighbors) == 0:
+            return candidates[0]
+
+        neighbors_x = neighbors[:, 0]
+        neighbors_y = neighbors[:, 1]
+        neighbors_r = neighbors[:, 2]
+
+        for xyr_i in candidates:
+
+            x_i, y_i, r_i = xyr_i
+
+            dx = neighbors_x - x_i
+            dy = neighbors_y - y_i
+            sq_distances = np.square(dx) + np.square(dy)
+
+            sep_needed = np.square(neighbors_r + r_i)
+
+            # Good candidate does not overlap any of neighbors which means that
+            # squared distance between candidate and any of the neighbors has
+            # to be at least square of the summed radii
+            good_candidate = np.all(sq_distances >= sep_needed)
+
+            if good_candidate:
+                return xyr_i
+
+        raise RuntimeError(
+            "No non-overlapping candidates found. This should not happen."
+        )
+
+    def add_gutters(self, points, center):
+        """Stop points from extending beyond their territory."""
+        half_width = self.width / 2
+        low_gutter = center - half_width
+        off_low = points < low_gutter
+        if off_low.any():
+            points[off_low] = low_gutter
+        high_gutter = center + half_width
+        off_high = points > high_gutter
+        if off_high.any():
+            points[off_high] = high_gutter
+
+        gutter_prop = (off_high + off_low).sum() / len(points)
+        if gutter_prop > self.warn_gutter_prop:
+            msg = (
+                "{:.1%} of the points cannot be placed; you may want "
+                "to decrease the size of the markers or use stripplot."
+            ).format(gutter_prop)
+            warnings.warn(msg, UserWarning)
+
+        return points

diff --git a/seaborn/tests/test_categorical.py b/seaborn/tests/test_categorical.py
index 871d5947fd..3cbf190747 100644
--- a/seaborn/tests/test_categorical.py
+++ b/seaborn/tests/test_categorical.py
@@ -1,4 +1,5 @@
 import itertools
+import warnings
 import numpy as np
 import pandas as pd
 import matplotlib as mpl
@@ -12,6 +13,7 @@
 from numpy.testing import (
     assert_array_equal,
     assert_array_almost_equal,
+    assert_array_less,
 )

 from .. import categorical as cat
@@ -20,8 +22,10 @@
 from .._core import categorical_order
 from ..categorical import (
     _CategoricalPlotterNew,
+    Beeswarm,
     catplot,
     stripplot,
+    swarmplot,
 )
 from ..palettes import color_palette
 from ..utils import _normal_quantile_func, _draw_figure
@@ -2093,196 +2097,125 @@ def test_vs_catplot(self, long_df, wide_df, kwargs):
         assert_plots_equal(ax, g.ax)

-class TestSwarmPlotter(CategoricalFixture):
+class TestSwarmPlot:

-    default_kws = dict(x=None, y=None, hue=None, data=None,
-                       order=None, hue_order=None, dodge=False,
-                       orient=None, color=None, palette=None)
-
-    def test_could_overlap(self):
-
-        p = cat._SwarmPlotter(**self.default_kws)
-        neighbors = p.could_overlap((1, 1), [(0, 0), (1, .5), (.5, .5)], 1)
-        npt.assert_array_equal(neighbors, [(1, .5), (.5, .5)])
-
-    def test_position_candidates(self):
-
-        p = cat._SwarmPlotter(**self.default_kws)
-        xy_i = (0, 1)
-        neighbors = [(0, 1), (0, 1.5)]
-        candidates = p.position_candidates(xy_i, neighbors, 1)
-        dx1 = 1.05
-        dx2 = np.sqrt(1 - .5 ** 2) * 1.05
-        npt.assert_array_equal(candidates,
-                               [(0, 1), (-dx1, 1), (dx1, 1),
-                                (dx2, 1), (-dx2, 1)])
-
-    def test_find_first_non_overlapping_candidate(self):
-
-        p = cat._SwarmPlotter(**self.default_kws)
-        candidates = [(.5, 1), (1, 1), (1.5, 1)]
-        neighbors = np.array([(0, 1)])
-
-        first = p.first_non_overlapping_candidate(candidates, neighbors, 1)
-        npt.assert_array_equal(first, (1, 1))
-
-    def test_beeswarm(self):
-
-        p = cat._SwarmPlotter(**self.default_kws)
-        d = self.y.diff().mean() * 1.5
-        x = np.zeros(self.y.size)
-        y = np.sort(self.y)
-        orig_xy = np.c_[x, y]
-        swarm = p.beeswarm(orig_xy, d)
-        dmat = np.sqrt(np.sum(np.square(swarm[:, np.newaxis] - swarm), axis=-1))
-        triu = dmat[np.triu_indices_from(dmat, 1)]
-        npt.assert_array_less(d, triu)
-        npt.assert_array_equal(y, swarm[:, 1])
-
-    def test_add_gutters(self):
-
-        p = cat._SwarmPlotter(**self.default_kws)
-
-        points = np.zeros(10)
-        assert np.array_equal(points, p.add_gutters(points, 0, 1))
-
-        points = np.array([0, -1, .4, .8])
-        msg = r"50.0% of the points cannot be placed.+$"
-        with pytest.warns(UserWarning, match=msg):
-            new_points = p.add_gutters(points, 0, 1)
-        assert np.array_equal(new_points, np.array([0, -.5, .4, .5]))
-
-    def test_swarmplot_vertical(self):
-
-        pal = palettes.color_palette()
-
-        ax = cat.swarmplot(x="g", y="y", data=self.df)
-        for i, (_, vals) in enumerate(self.y.groupby(self.g)):
-
-            x, y = ax.collections[i].get_offsets().T
-            npt.assert_array_almost_equal(y, np.sort(vals))
-
-            fc = ax.collections[i].get_facecolors()[0, :3]
-            npt.assert_equal(fc, pal[i])
-
-    def test_swarmplot_horizontal(self):
-
-        pal = palettes.color_palette()
-
-        ax = cat.swarmplot(x="y", y="g", data=self.df, orient="h")
-        for i, (_, vals) in enumerate(self.y.groupby(self.g)):
-
-            x, y = ax.collections[i].get_offsets().T
-            npt.assert_array_almost_equal(x, np.sort(vals))
-
-            fc = ax.collections[i].get_facecolors()[0, :3]
-            npt.assert_equal(fc, pal[i])
-
-    def test_dodge_nested_swarmplot_vertical(self):
-
-        pal = palettes.color_palette()
-
-        ax = cat.swarmplot(x="g", y="y", hue="h", data=self.df, dodge=True)
-        for i, (_, group_vals) in enumerate(self.y.groupby(self.g)):
-            for j, (_, vals) in enumerate(group_vals.groupby(self.h)):
-
-                x, y = ax.collections[i * 2 + j].get_offsets().T
-                npt.assert_array_almost_equal(y, np.sort(vals))
-
-                fc = ax.collections[i * 2 + j].get_facecolors()[0, :3]
-                assert tuple(fc) == pal[j]
-
-    def test_dodge_nested_swarmplot_horizontal(self):
+    @pytest.mark.parametrize(
+        "variables,orient",
+        [
+            # Order matters for assigning to x/y
+            ({"cat": "a", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "a", "hue": None}, None),
+            ({"cat": "a", "val": "y", "hue": "a"}, None),
+            ({"val": "y", "cat": "a", "hue": "a"}, None),
+            ({"cat": "a", "val": "y", "hue": "b"}, None),
+            ({"val": "y", "cat": "a", "hue": "x"}, None),
+            ({"cat": "s", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "s", "hue": None}, "h"),
+            ({"cat": "a", "val": "b", "hue": None}, None),
+            ({"val": "a", "cat": "b", "hue": None}, "h"),
+            ({"cat": "a", "val": "t", "hue": None}, None),
+            ({"val": "t", "cat": "a", "hue": None}, None),
+            pytest.param(
+                {"cat": "d", "val": "y", "hue": None}, None,
+                marks=pytest.mark.xfail(strict=True),
+            ),
+            pytest.param(
+                {"val": "y", "cat": "d", "hue": None}, None,
+                marks=pytest.mark.xfail(struct=True),
+            ),
+            ({"cat": "a_cat", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "s_cat", "hue": None}, None),
+        ],
+    )
+    def test_positions(self, long_df, variables, orient):

-        pal = palettes.color_palette()
+        cat_var = variables["cat"]
+        val_var = variables["val"]
+        hue_var = variables["hue"]
+        var_names = list(variables.values())
+        x_var, y_var, *_ = var_names

-        ax = cat.swarmplot(x="y", y="g", hue="h", data=self.df,
-                           orient="h", dodge=True)
-        for i, (_, group_vals) in enumerate(self.y.groupby(self.g)):
-            for j, (_, vals) in enumerate(group_vals.groupby(self.h)):
+        ax = swarmplot(
+            data=long_df, x=x_var, y=y_var, hue=hue_var, orient=orient,
+        )

-                x, y = ax.collections[i * 2 + j].get_offsets().T
-                npt.assert_array_almost_equal(x, np.sort(vals))
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")  # Ignore gutter warnings
+            _draw_figure(ax.figure)

-                fc = ax.collections[i * 2 + j].get_facecolors()[0, :3]
-                assert tuple(fc) == pal[j]
+        cat_idx = var_names.index(cat_var)
+        val_idx = var_names.index(val_var)

-    def test_nested_swarmplot_vertical(self):
+        axis_objs = ax.xaxis, ax.yaxis
+        cat_axis = axis_objs[cat_idx]
+        val_axis = axis_objs[val_idx]

-        ax = cat.swarmplot(x="g", y="y", hue="h", data=self.df)
+        cat_data = long_df[cat_var]
+        cat_levels = categorical_order(cat_data)

-        pal = palettes.color_palette()
-        hue_names = self.h.unique().tolist()
-        grouped_hues = list(self.h.groupby(self.g))
+        for i, label in enumerate(cat_levels):

-        for i, (_, vals) in enumerate(self.y.groupby(self.g)):
+            vals = long_df.loc[cat_data == label, val_var]

-            points = ax.collections[i]
-            x, y = points.get_offsets().T
-            sorter = np.argsort(vals)
-            npt.assert_array_almost_equal(y, vals.iloc[sorter])
+            points = ax.collections[i].get_offsets().T
+            cat_points = points[var_names.index(cat_var)]
+            val_points = points[var_names.index(val_var)]

-            _, hue_vals = grouped_hues[i]
-            for hue, fc in zip(hue_vals.values[sorter.values],
-                               points.get_facecolors()):
+            assert_array_equal(val_points, val_axis.convert_units(vals))
+            assert 0 <= np.ptp(cat_points) <= .8

-                assert tuple(fc[:3]) == pal[hue_names.index(hue)]
+            label = pd.Index([label]).astype(str)[0]
+            assert cat_axis.get_majorticklabels()[i].get_text() == label

-    def test_nested_swarmplot_horizontal(self):
+    @pytest.mark.parametrize(
+        "variables",
+        [
+            # Order matters for assigning to x/y
+            {"cat": "a", "val": "y", "hue": "b"},
+            {"val": "y", "cat": "a", "hue": "c"},
+            {"cat": "a", "val": "y", "hue": "f"},
+        ],
+    )
+    def test_positions_dodged(self, long_df, variables):

-        ax = cat.swarmplot(x="y", y="g", hue="h", data=self.df, orient="h")
+        cat_var = variables["cat"]
+        val_var = variables["val"]
+        hue_var = variables["hue"]
+        var_names = list(variables.values())
+        x_var, y_var, *_ = var_names

-        pal = palettes.color_palette()
-        hue_names = self.h.unique().tolist()
-        grouped_hues = list(self.h.groupby(self.g))
+        ax = swarmplot(
+            data=long_df, x=x_var, y=y_var, hue=hue_var, dodge=True,
+        )

-        for i, (_, vals) in enumerate(self.y.groupby(self.g)):
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")  # Ignore gutter warnings
+            _draw_figure(ax.figure)

-            points = ax.collections[i]
-            x, y = points.get_offsets().T
-            sorter = np.argsort(vals)
-            npt.assert_array_almost_equal(x, vals.iloc[sorter])
+        cat_vals = categorical_order(long_df[cat_var])
+        hue_vals = categorical_order(long_df[hue_var])

-            _, hue_vals = grouped_hues[i]
-            for hue, fc in zip(hue_vals.values[sorter.values],
-                               points.get_facecolors()):
+        width = .8
+        n_hue = len(hue_vals)
+        offsets = np.linspace(0, width, n_hue + 1)[:-1]
+        offsets -= offsets.mean()

-                assert tuple(fc[:3]) == pal[hue_names.index(hue)]
+        for i, cat_val in enumerate(cat_vals):
+            for j, hue_val in enumerate(hue_vals):
+                rows = (long_df[cat_var] == cat_val) & (long_df[hue_var] == hue_val)
+                vals = long_df.loc[rows, val_var]

-    def test_unaligned_index(self):
+                points = ax.collections[n_hue * i + j].get_offsets().T
+                cat_points = np.asarray(points[var_names.index(cat_var)])
+                val_points = np.asarray(points[var_names.index(val_var)])

-        f, (ax1, ax2) = plt.subplots(2)
-        cat.swarmplot(x=self.g, y=self.y, ax=ax1)
-        cat.swarmplot(x=self.g, y=self.y_perm, ax=ax2)
-        for p1, p2 in zip(ax1.collections, ax2.collections):
-            assert np.allclose(p1.get_offsets()[:, 1],
-                               p2.get_offsets()[:, 1])
-            assert np.array_equal(p1.get_facecolors(),
-                                  p2.get_facecolors())
+                if pd.api.types.is_datetime64_any_dtype(vals):
+                    vals = mpl.dates.date2num(vals)

-        f, (ax1, ax2) = plt.subplots(2)
-        hue_order = self.h.unique()
-        cat.swarmplot(x=self.g, y=self.y, hue=self.h,
-                      hue_order=hue_order, ax=ax1)
-        cat.swarmplot(x=self.g, y=self.y_perm, hue=self.h,
-                      hue_order=hue_order, ax=ax2)
-        for p1, p2 in zip(ax1.collections, ax2.collections):
-            assert np.allclose(p1.get_offsets()[:, 1],
-                               p2.get_offsets()[:, 1])
-            assert np.array_equal(p1.get_facecolors(),
-                                  p2.get_facecolors())
+                assert_array_equal(val_points, vals)

-        f, (ax1, ax2) = plt.subplots(2)
-        hue_order = self.h.unique()
-        cat.swarmplot(x=self.g, y=self.y, hue=self.h,
-                      dodge=True, hue_order=hue_order, ax=ax1)
-        cat.swarmplot(x=self.g, y=self.y_perm, hue=self.h,
-                      dodge=True, hue_order=hue_order, ax=ax2)
-        for p1, p2 in zip(ax1.collections, ax2.collections):
-            assert np.allclose(p1.get_offsets()[:, 1],
-                               p2.get_offsets()[:, 1])
-            assert np.array_equal(p1.get_facecolors(),
-                                  p2.get_facecolors())
+                assert 0 <= np.ptp(cat_points) <= (width / n_hue)
+                assert np.median(cat_points) == approx(i + offsets[j])

 class TestBarPlotter(CategoricalFixture):
@@ -3418,3 +3351,67 @@ def test_Float64_input(self):
         _ = cat.boxenplot(x="x", y="y", data=data)

         plt.close("all")
+
+
+class TestBeeswarm:
+
+    def test_could_overlap(self):
+
+        p = Beeswarm()
+        neighbors = p.could_overlap(
+            (1, 1, .5),
+            [(0, 0, .5),
+             (1, .1, .2),
+             (.5, .5, .5)]
+        )
+        assert_array_equal(neighbors, [(.5, .5, .5)])
+
+    def test_position_candidates(self):
+
+        p = Beeswarm()
+        xy_i = (0, 1, .5)
+        neighbors = [(0, 1, .5), (0, 1.5, .5)]
+        candidates = p.position_candidates(xy_i, neighbors)
+        dx1 = 1.05
+        dx2 = np.sqrt(1 - .5 ** 2) * 1.05
+        assert_array_equal(
+            candidates,
+            [(0, 1, .5), (-dx1, 1, .5), (dx1, 1, .5), (dx2, 1, .5), (-dx2, 1, .5)]
+        )
+
+    def test_find_first_non_overlapping_candidate(self):
+
+        p = Beeswarm()
+        candidates = [(.5, 1, .5), (1, 1, .5), (1.5, 1, .5)]
+        neighbors = np.array([(0, 1, .5)])
+
+        first = p.first_non_overlapping_candidate(candidates, neighbors)
+        assert_array_equal(first, (1, 1, .5))
+
+    def test_beeswarm(self, long_df):
+
+        p = Beeswarm()
+        data = long_df["y"]
+        d = data.diff().mean() * 1.5
+        x = np.zeros(data.size)
+        y = np.sort(data)
+        r = np.full_like(y, d)
+        orig_xyr = np.c_[x, y, r]
+        swarm = p.beeswarm(orig_xyr)[:, :2]
+        dmat = np.sqrt(np.sum(np.square(swarm[:, np.newaxis] - swarm), axis=-1))
+        triu = dmat[np.triu_indices_from(dmat, 1)]
+        assert_array_less(d, triu)
+        assert_array_equal(y, swarm[:, 1])
+
+    def test_add_gutters(self):
+
+        p = Beeswarm(width=1)
+
+        points = np.zeros(10)
+        assert_array_equal(points, p.add_gutters(points, 0))
+
+        points = np.array([0, -1, .4, .8])
+        msg = r"50.0% of the points cannot be placed.+$"
+        with pytest.warns(UserWarning, match=msg):
+            new_points = p.add_gutters(points, 0)
+        assert_array_equal(new_points, np.array([0, -.5, .4, .5]))

diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index 4a12cddb..07666794 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -10,6 +10,11 @@ v0.12.0 (Unreleased)

   - In :func:`stripplot`, a "strip" with a single observation will be plotted without jitter (:pr:`2413`)

+  - In :func:`swarmplot`, the points are now swarmed at draw time, meaning that the plot will adapt to further changes in axes scaling or tweaks to the plot layout.
+
+  - In :func:`swarmplot`, the order of the points in each swarm now matches the order in the original dataset; previously they were sorted. This affects only the underlying data stored in the matplotlib artist, not the visual representation.
+
+
 - Made `scipy` an optional dependency and added `pip install seaborn[all]` as a method for ensuring the availability of compatible `scipy` and `statsmodels` libraries at install time. This has a few minor implications for existing code, which are explained in the Github pull request (:pr:`2398`).

 - Following `NEP29 <https://numpy.org/neps/nep-0029-deprecation_policy.html>`_, dropped support for Python 3.6 and bumped the minimally-supported versions of the library dependencies.

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': 'Beeswarm'}, {'type': 'field', 'name': 'orig_xyr'}, {'type': 'class', 'name': 'Beeswarm'}]


diff --git a/doc/docstrings/FacetGrid.ipynb b/doc/docstrings/FacetGrid.ipynb
index bcd8b5c0f8..de6fc22a2e 100644
--- a/doc/docstrings/FacetGrid.ipynb
+++ b/doc/docstrings/FacetGrid.ipynb
@@ -262,7 +262,7 @@
    "source": [
     "g = sns.FacetGrid(tips, col=\"sex\", row=\"time\", margin_titles=True, despine=False)\n",
     "g.map_dataframe(sns.scatterplot, x=\"total_bill\", y=\"tip\")\n",
-    "g.fig.subplots_adjust(wspace=0, hspace=0)\n",
+    "g.figure.subplots_adjust(wspace=0, hspace=0)\n",
     "for (row_val, col_val), ax in g.axes_dict.items():\n",
     "    if row_val == \"Lunch\" and col_val == \"Female\":\n",
     "        ax.set_facecolor(\".95\")\n",
diff --git a/doc/introduction.ipynb b/doc/introduction.ipynb
index 9de6e5c9f9..3fca871f1d 100644
--- a/doc/introduction.ipynb
+++ b/doc/introduction.ipynb
@@ -417,7 +417,7 @@
     ")\n",
     "g.set_axis_labels(\"Bill length (mm)\", \"Bill depth (mm)\", labelpad=10)\n",
     "g.legend.set_title(\"Body mass (g)\")\n",
-    "g.fig.set_size_inches(6.5, 4.5)\n",
+    "g.figure.set_size_inches(6.5, 4.5)\n",
     "g.ax.margins(.15)\n",
     "g.despine(trim=True)"
    ]
diff --git a/doc/releases/v0.11.2.txt b/doc/releases/v0.11.2.txt
index 37318252f1..73f32c6d7c 100644
--- a/doc/releases/v0.11.2.txt
+++ b/doc/releases/v0.11.2.txt
@@ -12,10 +12,12 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |Feature| In :func:`kdeplot`, added the `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).

-- |Enhancement| In :class:`FacetGrid` and functions that use it, visibility of the interior axis labels is now disabled, and exterior axis labels are no longer erased when adding additional layers. This produces the same results for plots made by seaborn functions, but it may produce different (better, in most cases) results for customized facet plots (:pr:`2583`).
-
 - |Enhancement| In :func:`histplot`, improved performance with large datasets and many groupings/facets (:pr:`2559`, :pr:`2570`).

+- |Enhancement| The :class:`FacetGrid`, :class:`PairGrid`, and :class:`JointGrid` objects now reference the underlying matplotlib figure with a `.figure` attribute. The existing `.fig` attribute still exists but is discouraged and may eventually be deprecated. The effect is that you can now call `obj.figure` on the return value from any seaborn function to access the matplotlib object (:pr:`2639`).
+
+- |Enhancement| In :class:`FacetGrid` and functions that use it, visibility of the interior axis labels is now disabled, and exterior axis labels are no longer erased when adding additional layers. This produces the same results for plots made by seaborn functions, but it may produce different (better, in most cases) results for customized facet plots (:pr:`2583`).
+
 - |Enhancement| In :class:`FacetGrid`, :class:`PairGrid`, and functions that use them, the matplotlib `figure.autolayout` parameter is disabled to avoid having the legend overlap the plot (:pr:`2571`).

 - |Enhancement| The :func:`load_dataset` helper now produces a more informative error when fed a dataframe, easing a common beginner mistake (:pr:`2604`).
@@ -52,4 +54,4 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |Fix| Fixed an issue that prevented Python from running in `-OO` mode while using seaborn (:pr:`2473`).

-- |Docs| Improved the API documentation for theme-related functions (:pr:`2573`).
\ No newline at end of file
+- |Docs| Improved the API documentation for theme-related functions (:pr:`2573`).
diff --git a/doc/tutorial/axis_grids.ipynb b/doc/tutorial/axis_grids.ipynb
index 7c5707348b..756c732537 100644
--- a/doc/tutorial/axis_grids.ipynb
+++ b/doc/tutorial/axis_grids.ipynb
@@ -245,14 +245,14 @@
     "g.map(sns.scatterplot, \"total_bill\", \"tip\", color=\"#334488\")\n",
     "g.set_axis_labels(\"Total bill (US Dollars)\", \"Tip\")\n",
     "g.set(xticks=[10, 30, 50], yticks=[2, 6, 10])\n",
-    "g.fig.subplots_adjust(wspace=.02, hspace=.02)"
+    "g.figure.subplots_adjust(wspace=.02, hspace=.02)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``fig`` and ``axes`` (a two-dimensional array), respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes."
+    "For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``figure`` and ``axes_dict``, respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes."
    ]
   },
   {
@@ -263,7 +263,7 @@
    "source": [
     "g = sns.FacetGrid(tips, col=\"smoker\", margin_titles=True, height=4)\n",
     "g.map(plt.scatter, \"total_bill\", \"tip\", color=\"#338844\", edgecolor=\"white\", s=50, lw=1)\n",
-    "for ax in g.axes.flat:\n",
+    "for ax in g.axes_dict.values():\n",
     "    ax.axline((0, 0), slope=.2, c=\".2\", ls=\"--\", zorder=0)\n",
     "g.set(xlim=(0, 60), ylim=(0, 14))"
    ]
diff --git a/doc/tutorial/relational.ipynb b/doc/tutorial/relational.ipynb
index fd01a16c77..756eb3b513 100644
--- a/doc/tutorial/relational.ipynb
+++ b/doc/tutorial/relational.ipynb
@@ -224,7 +224,7 @@
     "df = pd.DataFrame(dict(time=np.arange(500),\n",
     "                       value=np.random.randn(500).cumsum()))\n",
     "g = sns.relplot(x=\"time\", y=\"value\", kind=\"line\", data=df)\n",
-    "g.fig.autofmt_xdate()"
+    "g.figure.autofmt_xdate()"
    ]
   },
   {
@@ -519,7 +519,7 @@
     "df = pd.DataFrame(dict(time=pd.date_range(\"2017-1-1\", periods=500),\n",
     "                       value=np.random.randn(500).cumsum()))\n",
     "g = sns.relplot(x=\"time\", y=\"value\", kind=\"line\", data=df)\n",
-    "g.fig.autofmt_xdate()"
+    "g.figure.autofmt_xdate()"
    ]
   },
   {
diff --git a/examples/joint_histogram.py b/examples/joint_histogram.py
index 5ad350a687..d0ae9ebe0c 100644
--- a/examples/joint_histogram.py
+++ b/examples/joint_histogram.py
@@ -16,7 +16,7 @@
 g.ax_joint.set(yscale="log")

 # Create an inset legend for the histogram colorbar
-cax = g.fig.add_axes([.15, .55, .02, .2])
+cax = g.figure.add_axes([.15, .55, .02, .2])

 # Add the joint and marginal histogram plots
 g.plot_joint(
diff --git a/examples/kde_ridgeplot.py b/examples/kde_ridgeplot.py
index 4f43c4b5f7..684df77df9 100644
--- a/examples/kde_ridgeplot.py
+++ b/examples/kde_ridgeplot.py
@@ -42,7 +42,7 @@ def label(x, color, label):
 g.map(label, "x")

 # Set the subplots to overlap
-g.fig.subplots_adjust(hspace=-.25)
+g.figure.subplots_adjust(hspace=-.25)

 # Remove axes details that don't play well with overlap
 g.set_titles("")
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index 7a1293198e..a52a0ee353 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -27,19 +27,8 @@
 )

-class Grid:
+class _BaseGrid:
     """Base class for grids of subplots."""
-    _margin_titles = False
-    _legend_out = True
-
-    def __init__(self):
-
-        self._tight_layout_rect = [0, 0, 1, 1]
-        self._tight_layout_pad = None
-
-        # This attribute is set externally and is a hack to handle newer functions that
-        # don't add proxy artists onto the Axes. We need an overall cleaner approach.
-        self._extract_legend_handles = False

     def set(self, **kwargs):
         """Set attributes on each subplot Axes."""
@@ -48,11 +37,46 @@ def set(self, **kwargs):
                 ax.set(**kwargs)
         return self

+    @property
+    def fig(self):
+        """DEPRECATED: prefer the `figure` property."""
+        # Grid.figure is preferred because it matches the Axes attribute name.
+        # But as the maintanace burden on having this property is minimal,
+        # let's be slow about formally deprecating it. For now just note its deprecation
+        # in the docstring; add a warning in version 0.13, and eventually remove it.
+        return self._figure
+
+    @property
+    def figure(self):
+        """Access the :class:`matplotlib.figure.Figure` object underlying the grid."""
+        return self._figure
+
     def savefig(self, *args, **kwargs):
-        """Save the figure."""
+        """
+        Save an image of the plot.
+
+        This wraps :meth:`matplotlib.figure.Figure.savefig`, using bbox_inches="tight"
+        by default. Parameters are passed through to the matplotlib function.
+
+        """
         kwargs = kwargs.copy()
         kwargs.setdefault("bbox_inches", "tight")
-        self.fig.savefig(*args, **kwargs)
+        self.figure.savefig(*args, **kwargs)
+
+
+class Grid(_BaseGrid):
+    """A grid that can have multiple subplots and an external legend."""
+    _margin_titles = False
+    _legend_out = True
+
+    def __init__(self):
+
+        self._tight_layout_rect = [0, 0, 1, 1]
+        self._tight_layout_pad = None
+
+        # This attribute is set externally and is a hack to handle newer functions that
+        # don't add proxy artists onto the Axes. We need an overall cleaner approach.
+        self._extract_legend_handles = False

     def tight_layout(self, *args, **kwargs):
         """Call fig.tight_layout within rect that exclude the legend."""
@@ -60,7 +84,7 @@ def tight_layout(self, *args, **kwargs):
         kwargs.setdefault("rect", self._tight_layout_rect)
         if self._tight_layout_pad is not None:
             kwargs.setdefault("pad", self._tight_layout_pad)
-        self.fig.tight_layout(*args, **kwargs)
+        self._figure.tight_layout(*args, **kwargs)

     def add_legend(self, legend_data=None, title=None, label_order=None,
                    adjust_subtitles=False, **kwargs):
@@ -122,7 +146,7 @@ def add_legend(self, legend_data=None, title=None, label_order=None,
             kwargs.setdefault("loc", "center right")

             # Draw a full-figure legend outside the grid
-            figlegend = self.fig.legend(handles, labels, **kwargs)
+            figlegend = self._figure.legend(handles, labels, **kwargs)

             self._legend = figlegend
             figlegend.set_title(title, prop={"size": title_size})
@@ -131,25 +155,25 @@ def add_legend(self, legend_data=None, title=None, label_order=None,
                 adjust_legend_subtitles(figlegend)

             # Draw the plot to set the bounding boxes correctly
-            _draw_figure(self.fig)
+            _draw_figure(self._figure)

             # Calculate and set the new width of the figure so the legend fits
-            legend_width = figlegend.get_window_extent().width / self.fig.dpi
-            fig_width, fig_height = self.fig.get_size_inches()
-            self.fig.set_size_inches(fig_width + legend_width, fig_height)
+            legend_width = figlegend.get_window_extent().width / self._figure.dpi
+            fig_width, fig_height = self._figure.get_size_inches()
+            self._figure.set_size_inches(fig_width + legend_width, fig_height)

             # Draw the plot again to get the new transformations
-            _draw_figure(self.fig)
+            _draw_figure(self._figure)

             # Now calculate how much space we need on the right side
-            legend_width = figlegend.get_window_extent().width / self.fig.dpi
+            legend_width = figlegend.get_window_extent().width / self._figure.dpi
             space_needed = legend_width / (fig_width + legend_width)
             margin = .04 if self._margin_titles else .01
             self._space_needed = margin + space_needed
             right = 1 - self._space_needed

             # Place the subplot axes to give space for the legend
-            self.fig.subplots_adjust(right=right)
+            self._figure.subplots_adjust(right=right)
             self._tight_layout_rect[2] = right

         else:
@@ -418,7 +442,7 @@ def __init__(

         # Attributes that are part of the public API but accessed through
         # a  property so that Sphinx adds them to the auto class doc
-        self._fig = fig
+        self._figure = fig
         self._axes = axes
         self._axes_dict = axes_dict
         self._legend = None
@@ -801,7 +825,7 @@ def facet_axis(self, row_i, col_j, modify_state=True):

     def despine(self, **kwargs):
         """Remove axis spines from the facets."""
-        utils.despine(self.fig, **kwargs)
+        utils.despine(self._figure, **kwargs)
         return self

     def set_axis_labels(self, x_var=None, y_var=None, clear_inner=True, **kwargs):
@@ -991,11 +1015,6 @@ def refline(self, *, x=None, y=None, color='.5', linestyle='--', **line_kws):

     # ------ Properties that are part of the public API and documented by Sphinx

-    @property
-    def fig(self):
-        """The :class:`matplotlib.figure.Figure` with the plot."""
-        return self._fig
-
     @property
     def axes(self):
         """An array of the :class:`matplotlib.axes.Axes` objects in the grid."""
@@ -1233,7 +1252,7 @@ def __init__(
                 axes[i, j].remove()
                 axes[i, j] = None

-        self.fig = fig
+        self._figure = fig
         self.axes = axes
         self.data = data

@@ -1618,7 +1637,7 @@ def _find_numeric_cols(self, data):
         return numeric_cols

-class JointGrid(object):
+class JointGrid(_BaseGrid):
     """Grid for drawing a bivariate plot with marginal univariate plots.

     Many plots can be drawn by using the figure-level interface :func:`jointplot`.
@@ -1650,7 +1669,7 @@ def __init__(
         ax_marg_x = f.add_subplot(gs[0, :-1], sharex=ax_joint)
         ax_marg_y = f.add_subplot(gs[1:, -1], sharey=ax_joint)

-        self.fig = f
+        self._figure = f
         self.ax_joint = ax_joint
         self.ax_marg_x = ax_marg_x
         self.ax_marg_y = ax_marg_y
@@ -1913,15 +1932,6 @@ def set_axis_labels(self, xlabel="", ylabel="", **kwargs):
         self.ax_joint.set_ylabel(ylabel, **kwargs)
         return self

-    def savefig(self, *args, **kwargs):
-        """Save the figure using a "tight" bounding box by default.
-
-        Wraps :meth:`matplotlib.figure.Figure.savefig`.
-
-        """
-        kwargs.setdefault("bbox_inches", "tight")
-        self.fig.savefig(*args, **kwargs)
-

 JointGrid.__init__.__doc__ = """\
 Set up the grid of subplots and store data internally for easy plotting.
diff --git a/seaborn/matrix.py b/seaborn/matrix.py
index 2f550026f5..0b3ea62ce1 100644
--- a/seaborn/matrix.py
+++ b/seaborn/matrix.py
@@ -811,7 +811,7 @@ def __init__(self, data, pivot_kws=None, z_score=None, standard_scale=None,

         self.mask = _matrix_mask(self.data2d, mask)

-        self.fig = plt.figure(figsize=figsize)
+        self._figure = plt.figure(figsize=figsize)

         self.row_colors, self.row_color_labels = \
             self._preprocess_colors(data, row_colors, axis=0)
@@ -842,8 +842,8 @@ def __init__(self, data, pivot_kws=None, z_score=None, standard_scale=None,
                                     width_ratios=width_ratios,
                                     height_ratios=height_ratios)

-        self.ax_row_dendrogram = self.fig.add_subplot(self.gs[-1, 0])
-        self.ax_col_dendrogram = self.fig.add_subplot(self.gs[0, -1])
+        self.ax_row_dendrogram = self._figure.add_subplot(self.gs[-1, 0])
+        self.ax_col_dendrogram = self._figure.add_subplot(self.gs[0, -1])
         self.ax_row_dendrogram.set_axis_off()
         self.ax_col_dendrogram.set_axis_off()

@@ -851,19 +851,19 @@ def __init__(self, data, pivot_kws=None, z_score=None, standard_scale=None,
         self.ax_col_colors = None

         if self.row_colors is not None:
-            self.ax_row_colors = self.fig.add_subplot(
+            self.ax_row_colors = self._figure.add_subplot(
                 self.gs[-1, 1])
         if self.col_colors is not None:
-            self.ax_col_colors = self.fig.add_subplot(
+            self.ax_col_colors = self._figure.add_subplot(
                 self.gs[1, -1])

-        self.ax_heatmap = self.fig.add_subplot(self.gs[-1, -1])
+        self.ax_heatmap = self._figure.add_subplot(self.gs[-1, -1])
         if cbar_pos is None:
             self.ax_cbar = self.cax = None
         else:
             # Initialize the colorbar axes in the gridspec so that tight_layout
             # works. We will move it where it belongs later. This is a hack.
-            self.ax_cbar = self.fig.add_subplot(self.gs[0, 0])
+            self.ax_cbar = self._figure.add_subplot(self.gs[0, 0])
             self.cax = self.ax_cbar  # Backwards compatibility
         self.cbar_pos = cbar_pos

@@ -1066,11 +1066,6 @@ def color_list_to_matrix_and_cmap(colors, ind, axis=0):
         cmap = mpl.colors.ListedColormap(list(unique_colors))
         return matrix, cmap

-    def savefig(self, *args, **kwargs):
-        if 'bbox_inches' not in kwargs:
-            kwargs['bbox_inches'] = 'tight'
-        self.fig.savefig(*args, **kwargs)
-
     def plot_dendrograms(self, row_cluster, col_cluster, metric, method,
                          row_linkage, col_linkage, tree_kws):
         # Plot the row dendrogram
@@ -1208,13 +1203,13 @@ def plot_matrix(self, colorbar_kws, xind, yind, **kws):

         tight_params = dict(h_pad=.02, w_pad=.02)
         if self.ax_cbar is None:
-            self.fig.tight_layout(**tight_params)
+            self._figure.tight_layout(**tight_params)
         else:
             # Turn the colorbar axes off for tight layout so that its
             # ticks don't interfere with the rest of the plot layout.
             # Then move it.
             self.ax_cbar.set_axis_off()
-            self.fig.tight_layout(**tight_params)
+            self._figure.tight_layout(**tight_params)
             self.ax_cbar.set_axis_on()
             self.ax_cbar.set_position(self.cbar_pos)

diff --git a/seaborn/tests/test_axisgrid.py b/seaborn/tests/test_axisgrid.py
index 719aceb1ba..699dd7ac83 100644
--- a/seaborn/tests/test_axisgrid.py
+++ b/seaborn/tests/test_axisgrid.py
@@ -40,10 +40,11 @@ def test_self_data(self):
         g = ag.FacetGrid(self.df)
         assert g.data is self.df

-    def test_self_fig(self):
+    def test_self_figure(self):

         g = ag.FacetGrid(self.df)
-        assert isinstance(g.fig, plt.Figure)
+        assert isinstance(g.figure, plt.Figure)
+        assert g.figure is g._figure

     def test_self_axes(self):

@@ -189,26 +190,26 @@ def test_axes_dict(self):
     def test_figure_size(self):

         g = ag.FacetGrid(self.df, row="a", col="b")
-        npt.assert_array_equal(g.fig.get_size_inches(), (6, 9))
+        npt.assert_array_equal(g.figure.get_size_inches(), (6, 9))

         g = ag.FacetGrid(self.df, row="a", col="b", height=6)
-        npt.assert_array_equal(g.fig.get_size_inches(), (12, 18))
+        npt.assert_array_equal(g.figure.get_size_inches(), (12, 18))

         g = ag.FacetGrid(self.df, col="c", height=4, aspect=.5)
-        npt.assert_array_equal(g.fig.get_size_inches(), (6, 4))
+        npt.assert_array_equal(g.figure.get_size_inches(), (6, 4))

     def test_figure_size_with_legend(self):

         g = ag.FacetGrid(self.df, col="a", hue="c", height=4, aspect=.5)
-        npt.assert_array_equal(g.fig.get_size_inches(), (6, 4))
+        npt.assert_array_equal(g.figure.get_size_inches(), (6, 4))
         g.add_legend()
-        assert g.fig.get_size_inches()[0] > 6
+        assert g.figure.get_size_inches()[0] > 6

         g = ag.FacetGrid(self.df, col="a", hue="c", height=4, aspect=.5,
                          legend_out=False)
-        npt.assert_array_equal(g.fig.get_size_inches(), (6, 4))
+        npt.assert_array_equal(g.figure.get_size_inches(), (6, 4))
         g.add_legend()
-        npt.assert_array_equal(g.fig.get_size_inches(), (6, 4))
+        npt.assert_array_equal(g.figure.get_size_inches(), (6, 4))

     def test_legend_data(self):

@@ -342,7 +343,7 @@ def test_gridspec_kws(self):
             ax.set_xticks([])
             ax.set_yticks([])

-        g.fig.tight_layout()
+        g.figure.tight_layout()

         for (l, m, r) in g.axes:
             assert l.get_position().width > m.get_position().width
@@ -695,10 +696,11 @@ def test_ignore_datelike_data(self):
         expected = df.drop('date', axis=1)
         tm.assert_frame_equal(result, expected)

-    def test_self_fig(self):
+    def test_self_figure(self):

         g = ag.PairGrid(self.df)
-        assert isinstance(g.fig, plt.Figure)
+        assert isinstance(g.figure, plt.Figure)
+        assert g.figure is g._figure

     def test_self_axes(self):

@@ -756,10 +758,10 @@ def test_corner(self):
         plot_vars = ["x", "y", "z"]
         g = ag.PairGrid(self.df, vars=plot_vars, corner=True)
         corner_size = sum([i + 1 for i in range(len(plot_vars))])
-        assert len(g.fig.axes) == corner_size
+        assert len(g.figure.axes) == corner_size

         g.map_diag(plt.hist)
-        assert len(g.fig.axes) == (corner_size + len(plot_vars))
+        assert len(g.figure.axes) == (corner_size + len(plot_vars))

         for ax in np.diag(g.axes):
             assert not ax.yaxis.get_visible()
@@ -768,7 +770,7 @@ def test_corner(self):
         plot_vars = ["x", "y", "z"]
         g = ag.PairGrid(self.df, vars=plot_vars, corner=True)
         g.map(scatterplot)
-        assert len(g.fig.axes) == corner_size
+        assert len(g.figure.axes) == corner_size

     def test_size(self):

--- a/doc/docstrings/FacetGrid.ipynb
+++ b/doc/docstrings/FacetGrid.ipynb
@@ -87,7 +87,7 @@

 g.map_dataframe(sns.scatterplot, x="total_bill", y="tip")

-g.fig.subplots_adjust(wspace=0, hspace=0)
+g.figure.subplots_adjust(wspace=0, hspace=0)

 for (row_val, col_val), ax in g.axes_dict.items():

--- a/doc/introduction.ipynb
+++ b/doc/introduction.ipynb
@@ -265,7 +265,7 @@

 g.legend.set_title("Body mass (g)")

-g.fig.set_size_inches(6.5, 4.5)
+g.figure.set_size_inches(6.5, 4.5)

 g.ax.margins(.15)

--- a/doc/tutorial/axis_grids.ipynb
+++ b/doc/tutorial/axis_grids.ipynb
@@ -110,13 +110,13 @@

 g.set(xticks=[10, 30, 50], yticks=[2, 6, 10])

-g.fig.subplots_adjust(wspace=.02, hspace=.02)
-For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``fig`` and ``axes`` (a two-dimensional array), respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes.
+g.figure.subplots_adjust(wspace=.02, hspace=.02)
+For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``figure`` and ``axes_dict``, respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes.
 g = sns.FacetGrid(tips, col="smoker", margin_titles=True, height=4)

 g.map(plt.scatter, "total_bill", "tip", color="#338844", edgecolor="white", s=50, lw=1)

-for ax in g.axes.flat:
+for ax in g.axes_dict.values():

     ax.axline((0, 0), slope=.2, c=".2", ls="--", zorder=0)

--- a/doc/tutorial/relational.ipynb
+++ b/doc/tutorial/relational.ipynb
@@ -100,7 +100,7 @@

 g = sns.relplot(x="time", y="value", kind="line", data=df)

-g.fig.autofmt_xdate()
+g.figure.autofmt_xdate()
 Because :func:`lineplot` assumes that you are most often trying to draw ``y`` as a function of ``x``, the default behavior is to sort the data by the ``x`` values before plotting. However, this can be disabled:
 df = pd.DataFrame(np.random.randn(500, 2).cumsum(axis=0), columns=["x", "y"])

@@ -210,7 +210,7 @@

 g = sns.relplot(x="time", y="value", kind="line", data=df)

-g.fig.autofmt_xdate()
+g.figure.autofmt_xdate()
 Showing multiple relationships with facets

 ------------------------------------------

diff --git a/doc/docstrings/FacetGrid.ipynb b/doc/docstrings/FacetGrid.ipynb
index bcd8b5c0..de6fc22a 100644
--- a/doc/docstrings/FacetGrid.ipynb
+++ b/doc/docstrings/FacetGrid.ipynb
@@ -262,7 +262,7 @@
    "source": [
     "g = sns.FacetGrid(tips, col=\"sex\", row=\"time\", margin_titles=True, despine=False)\n",
     "g.map_dataframe(sns.scatterplot, x=\"total_bill\", y=\"tip\")\n",
-    "g.fig.subplots_adjust(wspace=0, hspace=0)\n",
+    "g.figure.subplots_adjust(wspace=0, hspace=0)\n",
     "for (row_val, col_val), ax in g.axes_dict.items():\n",
     "    if row_val == \"Lunch\" and col_val == \"Female\":\n",
     "        ax.set_facecolor(\".95\")\n",

diff --git a/doc/introduction.ipynb b/doc/introduction.ipynb
index 9de6e5c9..3fca871f 100644
--- a/doc/introduction.ipynb
+++ b/doc/introduction.ipynb
@@ -417,7 +417,7 @@
     ")\n",
     "g.set_axis_labels(\"Bill length (mm)\", \"Bill depth (mm)\", labelpad=10)\n",
     "g.legend.set_title(\"Body mass (g)\")\n",
-    "g.fig.set_size_inches(6.5, 4.5)\n",
+    "g.figure.set_size_inches(6.5, 4.5)\n",
     "g.ax.margins(.15)\n",
     "g.despine(trim=True)"
    ]

diff --git a/doc/releases/v0.11.2.txt b/doc/releases/v0.11.2.txt
index 37318252..1275e5d2 100644
--- a/doc/releases/v0.11.2.txt
+++ b/doc/releases/v0.11.2.txt
@@ -12,10 +12,12 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |Feature| In :func:`kdeplot`, added the `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).

-- |Enhancement| In :class:`FacetGrid` and functions that use it, visibility of the interior axis labels is now disabled, and exterior axis labels are no longer erased when adding additional layers. This produces the same results for plots made by seaborn functions, but it may produce different (better, in most cases) results for customized facet plots (:pr:`2583`).
-
 - |Enhancement| In :func:`histplot`, improved performance with large datasets and many groupings/facets (:pr:`2559`, :pr:`2570`).

+- |Enhancement| The :class:`FacetGrid`, :class:`PairGrid`, and :class:`JointGrid` objects now reference the underlying matplotlib figure with a `.figure` attribute. The existing `.fig` attribute still exists but is discouraged and may eventually be deprecated. The effect is that you can now call `obj.figure` on the return value from any seaborn function to access the matplotlib object.
+
+- |Enhancement| In :class:`FacetGrid` and functions that use it, visibility of the interior axis labels is now disabled, and exterior axis labels are no longer erased when adding additional layers. This produces the same results for plots made by seaborn functions, but it may produce different (better, in most cases) results for customized facet plots (:pr:`2583`).
+
 - |Enhancement| In :class:`FacetGrid`, :class:`PairGrid`, and functions that use them, the matplotlib `figure.autolayout` parameter is disabled to avoid having the legend overlap the plot (:pr:`2571`).

 - |Enhancement| The :func:`load_dataset` helper now produces a more informative error when fed a dataframe, easing a common beginner mistake (:pr:`2604`).
@@ -52,4 +54,4 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |Fix| Fixed an issue that prevented Python from running in `-OO` mode while using seaborn (:pr:`2473`).

-- |Docs| Improved the API documentation for theme-related functions (:pr:`2573`).
\ No newline at end of file
+- |Docs| Improved the API documentation for theme-related functions (:pr:`2573`).

diff --git a/doc/tutorial/axis_grids.ipynb b/doc/tutorial/axis_grids.ipynb
index 7c570734..756c7325 100644
--- a/doc/tutorial/axis_grids.ipynb
+++ b/doc/tutorial/axis_grids.ipynb
@@ -245,14 +245,14 @@
     "g.map(sns.scatterplot, \"total_bill\", \"tip\", color=\"#334488\")\n",
     "g.set_axis_labels(\"Total bill (US Dollars)\", \"Tip\")\n",
     "g.set(xticks=[10, 30, 50], yticks=[2, 6, 10])\n",
-    "g.fig.subplots_adjust(wspace=.02, hspace=.02)"
+    "g.figure.subplots_adjust(wspace=.02, hspace=.02)"
    ]
   },
   {
    "cell_type": "raw",
    "metadata": {},
    "source": [
-    "For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``fig`` and ``axes`` (a two-dimensional array), respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes."
+    "For even more customization, you can  work directly with the underling matplotlib ``Figure`` and ``Axes`` objects, which are stored as member attributes at ``figure`` and ``axes_dict``, respectively. When making a figure without row or column faceting, you can also use the ``ax`` attribute to directly access the single axes."
    ]
   },
   {
@@ -263,7 +263,7 @@
    "source": [
     "g = sns.FacetGrid(tips, col=\"smoker\", margin_titles=True, height=4)\n",
     "g.map(plt.scatter, \"total_bill\", \"tip\", color=\"#338844\", edgecolor=\"white\", s=50, lw=1)\n",
-    "for ax in g.axes.flat:\n",
+    "for ax in g.axes_dict.values():\n",
     "    ax.axline((0, 0), slope=.2, c=\".2\", ls=\"--\", zorder=0)\n",
     "g.set(xlim=(0, 60), ylim=(0, 14))"
    ]

diff --git a/doc/tutorial/relational.ipynb b/doc/tutorial/relational.ipynb
index fd01a16c..756eb3b5 100644
--- a/doc/tutorial/relational.ipynb
+++ b/doc/tutorial/relational.ipynb
@@ -224,7 +224,7 @@
     "df = pd.DataFrame(dict(time=np.arange(500),\n",
     "                       value=np.random.randn(500).cumsum()))\n",
     "g = sns.relplot(x=\"time\", y=\"value\", kind=\"line\", data=df)\n",
-    "g.fig.autofmt_xdate()"
+    "g.figure.autofmt_xdate()"
    ]
   },
   {
@@ -519,7 +519,7 @@
     "df = pd.DataFrame(dict(time=pd.date_range(\"2017-1-1\", periods=500),\n",
     "                       value=np.random.randn(500).cumsum()))\n",
     "g = sns.relplot(x=\"time\", y=\"value\", kind=\"line\", data=df)\n",
-    "g.fig.autofmt_xdate()"
+    "g.figure.autofmt_xdate()"
    ]
   },
   {

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': '_figure'}, {'type': 'field', 'name': '_figure'}]


diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index a026fb99a8..4f90b48f29 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -51,6 +51,8 @@ Other updates

 - |Enhancement| Example datasets are now stored in an OS-specific cache location (as determined by `appdirs`) rather than in the user's home directory. Users should feel free to remove `~/seaborn-data` if desired (:pr:`2773`).

+- |Enhancement| When using :func:`pairplot` with `corner=True` and `diag_kind=None`, the top left y axis label is no longer hidden (:pr:2850`).
+
 - |Fix| Fixed a regression in 0.11.2 that caused some functions to stall indefinitely or raise when the input data had a duplicate index (:pr:`2776`).

 - |Fix| Fixed a bug in :func:`histplot` and :func:`kdeplot` where weights were not factored into the normalization (:pr:`2812`).
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index 8526f44b8e..56ff0aa8ef 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -1623,8 +1623,6 @@ def _add_axis_labels(self):
             ax.set_xlabel(label)
         for ax, label in zip(self.axes[:, 0], self.y_vars):
             ax.set_ylabel(label)
-        if self._corner:
-            self.axes[0, 0].set_ylabel("")

     def _find_numeric_cols(self, data):
         """Find which variables in a DataFrame are numeric."""

diff --git a/tests/test_axisgrid.py b/tests/test_axisgrid.py
index 40d591f184..398f2d4a76 100644
--- a/tests/test_axisgrid.py
+++ b/tests/test_axisgrid.py
@@ -765,12 +765,12 @@ def test_corner(self):

         for ax in np.diag(g.axes):
             assert not ax.yaxis.get_visible()
-            assert not g.axes[0, 0].get_ylabel()

         plot_vars = ["x", "y", "z"]
         g = ag.PairGrid(self.df, vars=plot_vars, corner=True)
         g.map(scatterplot)
         assert len(g.figure.axes) == corner_size
+        assert g.axes[0, 0].get_ylabel() == "x"

     def test_size(self):

diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index a026fb99..2f69bd1f 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -51,6 +51,8 @@ Other updates

 - |Enhancement| Example datasets are now stored in an OS-specific cache location (as determined by `appdirs`) rather than in the user's home directory. Users should feel free to remove `~/seaborn-data` if desired (:pr:`2773`).

+- |Enhancement| When using :func:`pairplot` with `corner=True` and `diag_kind=None`, the top left y axis label is no longer hidden.
+
 - |Fix| Fixed a regression in 0.11.2 that caused some functions to stall indefinitely or raise when the input data had a duplicate index (:pr:`2776`).

 - |Fix| Fixed a bug in :func:`histplot` and :func:`kdeplot` where weights were not factored into the normalization (:pr:`2812`).


diff --git a/doc/api.rst b/doc/api.rst
index 4d30f9dfe8..86db07d7b8 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -184,10 +184,11 @@ Utility functions
     :toctree: generated/
     :nosignatures:

-    load_dataset
-    get_dataset_names
-    get_data_home
     despine
-    desaturate
+    move_legend
     saturate
+    desaturate
     set_hls_values
+    load_dataset
+    get_dataset_names
+    get_data_home
diff --git a/doc/docstrings/move_legend.ipynb b/doc/docstrings/move_legend.ipynb
new file mode 100644
index 0000000000..705fedbf66
--- /dev/null
+++ b/doc/docstrings/move_legend.ipynb
@@ -0,0 +1,156 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8ec46ad8-bc4c-4ee0-9626-271088c702f9",
+   "metadata": {
+    "tags": [
+     "hide"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "import seaborn as sns\n",
+    "sns.set_theme()\n",
+    "penguins = sns.load_dataset(\"penguins\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "008bdd98-88cb-4a81-9f50-9b0e5a357305",
+   "metadata": {},
+   "source": [
+    "For axes-level functions, pass the :class:`matplotlib.axes.Axes` object and provide a new location."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b82e58f9-b15d-4554-bee5-de6a689344a6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(ax, \"center right\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "4f2a7f5d-ab39-46c7-87f4-532e607adf0b",
+   "metadata": {},
+   "source": [
+    "Use the `bbox_to_anchor` parameter for more fine-grained control, including moving the legend outside of the axes:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ed610a98-447a-4459-8342-48abc80330f0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(ax, \"upper left\", bbox_to_anchor=(1, 1))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "9d2fd766-a806-45d9-949d-1572991cf512",
+   "metadata": {},
+   "source": [
+    "Pass additional :meth:`matplotlib.axes.Axes.legend` parameters to update other properties:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5ad4342c-c46e-49e9-98a2-6c88c6fb4c54",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(\n",
+    "    ax, \"lower center\",\n",
+    "    bbox_to_anchor=(.5, 1), ncol=3, title=None, frameon=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "0d573092-46fd-4a95-b7ed-7e6833823adc",
+   "metadata": {},
+   "source": [
+    "It's also possible to move the legend created by a figure-level function. But when fine-tuning the position, you must bear in mind that the figure will have extra blank space on the right:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b258a9b8-69e5-4d4a-94cb-5b6baddc402b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    penguins,\n",
+    "    x=\"bill_length_mm\", hue=\"species\",\n",
+    "    col=\"island\", col_wrap=2, height=3,\n",
+    ")\n",
+    "sns.move_legend(g, \"upper left\", bbox_to_anchor=(.55, .45))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "c9dc54e2-2c66-412f-ab2a-4f2bc2cb5782",
+   "metadata": {},
+   "source": [
+    "One way to avoid this would be to set `legend_out=False` on the :class:`FacetGrid`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "06cff408-4cdf-47af-8def-176f3e70ec5a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    penguins,\n",
+    "    x=\"bill_length_mm\", hue=\"species\",\n",
+    "    col=\"island\", col_wrap=2, height=3,\n",
+    "    facet_kws=dict(legend_out=False),\n",
+    ")\n",
+    "sns.move_legend(g, \"upper left\", bbox_to_anchor=(.55, .45), frameon=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b170f20d-22a9-4f7d-917a-d09e10b1f08c",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-py38-latest",
+   "language": "python",
+   "name": "seaborn-py38-latest"
+  },
+  "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.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/doc/releases/v0.11.2.txt b/doc/releases/v0.11.2.txt
index a4181bad1a..2731b3911a 100644
--- a/doc/releases/v0.11.2.txt
+++ b/doc/releases/v0.11.2.txt
@@ -6,11 +6,13 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |API| |Enhancement| In :func:`lmplot`, added a new `facet_kws` parameter and deprecated the `sharex`, `sharey`, and `legend_out` parameters from the function signature; pass them in a `facet_kws` dictionary instead (:pr:`2576`).

+- |Feature| Added a :func:`move_legend` convenience function for repositioning the legend on an existing axes or figure, along with updating its properties. This function should be preferred over calling `ax.legend` with no legend data, which does not reliably work across seaborn plot types (:pr:`2643`).
+
 - |Feature| In :func:`histplot`, added `stat="percent"` as an option for normalization such that bar heights sum to 100 and `stat="proportion"` as an alias for the existing `stat="probability"`: (:pr:`2461`, :pr:`2634`).

 - |Feature| Added a ``refline`` method to :class:`FacetGrid` and :class:`JointGrid` for adding horizontal and/or vertical reference lines to every subplot in one step (:pr:`2620`).

-- |Feature| In :func:`kdeplot`, added the `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).
+- |Feature| In :func:`kdeplot`, added a `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).

 - |Enhancement| In :func:`histplot`, improved performance with large datasets and many groupings/facets (:pr:`2559`, :pr:`2570`).

diff --git a/seaborn/utils.py b/seaborn/utils.py
index a9261028b9..9d5f86ae6d 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -15,7 +15,7 @@
 from matplotlib.cbook import normalize_kwargs

-__all__ = ["desaturate", "saturate", "set_hls_values",
+__all__ = ["desaturate", "saturate", "set_hls_values", "move_legend",
            "despine", "get_dataset_names", "get_data_home", "load_dataset"]

@@ -390,6 +390,96 @@ def despine(fig=None, ax=None, top=True, right=True, left=False,
                 ax_i.set_yticks(newticks)

+def move_legend(obj, loc, **kwargs):
+    """
+    Recreate a plot's legend at a new location.
+
+    The name is a slight misnomer. Matplotlib legends do not expose public
+    control over their position parameters. So this function creates a new legend,
+    copying over the data from the original object, which is then removed.
+
+    Parameters
+    ----------
+    obj : the object with the plot
+        This argument can be either a seaborn or matplotlib object:
+
+        - :class:`seaborn.FacetGrid` or :class:`seaborn.PairGrid`
+        - :class:`matplotlib.axes.Axes` or :class:`matplotlib.figure.Figure`
+
+    loc : str or int
+        Location argument, as in :meth:`matplotlib.axes.Axes.legend`.
+
+    kwargs
+        Other keyword arguments are passed to :meth:`matplotlib.axes.Axes.legend`.
+
+    Examples
+    --------
+
+    .. include:: ../docstrings/move_legend.rst
+
+    """
+    # This is a somewhat hackish solution that will hopefully be obviated by
+    # upstream improvements to matplotlib legends that make them easier to
+    # modify after creation.
+
+    from seaborn.axisgrid import Grid  # Avoid circular import
+
+    # Locate the legend object and a method to recreate the legend
+    if isinstance(obj, Grid):
+        old_legend = obj.legend
+        legend_func = obj.figure.legend
+    elif isinstance(obj, mpl.axes.Axes):
+        old_legend = obj.legend_
+        legend_func = obj.legend
+    elif isinstance(obj, mpl.figure.Figure):
+        if obj.legends:
+            old_legend = obj.legends[-1]
+        else:
+            old_legend = None
+        legend_func = obj.legend
+    else:
+        err = "`obj` must be a seaborn Grid or matplotlib Axes or Figure instance."
+        raise TypeError(err)
+
+    if old_legend is None:
+        err = f"{obj} has no legend attached."
+        raise ValueError(err)
+
+    # Extract the components of the legend we need to reuse
+    handles = old_legend.legendHandles
+    labels = [t.get_text() for t in old_legend.get_texts()]
+
+    # Extract legend properties that can be passed to the recreation method
+    # (Vexingly, these don't all round-trip)
+    legend_kws = inspect.signature(mpl.legend.Legend).parameters
+    props = {k: v for k, v in old_legend.properties().items() if k in legend_kws}
+
+    # Delegate default bbox_to_anchor rules to matplotlib
+    props.pop("bbox_to_anchor")
+
+    # Try to propagate the existing title and font properties; respect new ones too
+    title = props.pop("title")
+    if "title" in kwargs:
+        title.set_text(kwargs.pop("title"))
+    title_kwargs = {k: v for k, v in kwargs.items() if k.startswith("title_")}
+    for key, val in title_kwargs.items():
+        title.set(**{key[6:]: val})
+        kwargs.pop(key)
+
+    # Try to respect the frame visibility
+    kwargs.setdefault("frameon", old_legend.legendPatch.get_visible())
+
+    # Remove the old legend and create the new one
+    props.update(kwargs)
+    old_legend.remove()
+    new_legend = legend_func(handles, labels, loc=loc, **props)
+    new_legend.set_title(title.get_text(), title.get_fontproperties())
+
+    # Let the Grid object continue to track the correct legend object
+    if isinstance(obj, Grid):
+        obj._legend = new_legend
+
+
 def _kde_support(data, bw, gridsize, cut, clip):
     """Establish support for a kernel density estimate."""
     support_min = max(data.min() - bw * cut, clip[0])

diff --git a/seaborn/tests/test_utils.py b/seaborn/tests/test_utils.py
index d264935b89..587a2dcfa4 100644
--- a/seaborn/tests/test_utils.py
+++ b/seaborn/tests/test_utils.py
@@ -336,6 +336,96 @@ def test_locator_to_legend_entries():
         assert str_levels == ['1e-04', '1e-03', '1e-02']

+def test_move_legend_matplotlib_objects():
+
+    fig, ax = plt.subplots()
+
+    colors = "C2", "C5"
+    labels = "first label", "second label"
+    title = "the legend"
+
+    for color, label in zip(colors, labels):
+        ax.plot([0, 1], color=color, label=label)
+    ax.legend(loc="upper right", title=title)
+    utils._draw_figure(fig)
+    xfm = ax.transAxes.inverted().transform
+
+    # --- Test axes legend
+
+    old_pos = xfm(ax.legend_.legendPatch.get_extents())
+
+    new_fontsize = 14
+    utils.move_legend(ax, "lower left", title_fontsize=new_fontsize)
+    utils._draw_figure(fig)
+    new_pos = xfm(ax.legend_.legendPatch.get_extents())
+
+    assert (new_pos < old_pos).all()
+    assert ax.legend_.get_title().get_text() == title
+    assert ax.legend_.get_title().get_size() == new_fontsize
+
+    # --- Test title replacement
+
+    new_title = "new title"
+    utils.move_legend(ax, "lower left", title=new_title)
+    utils._draw_figure(fig)
+    assert ax.legend_.get_title().get_text() == new_title
+
+    # --- Test figure legend
+
+    fig.legend(loc="upper right", title=title)
+    _draw_figure(fig)
+    xfm = fig.transFigure.inverted().transform
+    old_pos = xfm(fig.legends[0].legendPatch.get_extents())
+
+    utils.move_legend(fig, "lower left", title=new_title)
+    _draw_figure(fig)
+
+    new_pos = xfm(fig.legends[0].legendPatch.get_extents())
+    assert (new_pos < old_pos).all()
+    assert fig.legends[0].get_title().get_text() == new_title
+
+
+def test_move_legend_grid_object(long_df):
+
+    from seaborn.axisgrid import FacetGrid
+
+    hue_var = "a"
+    g = FacetGrid(long_df, hue=hue_var)
+    g.map(plt.plot, "x", "y")
+
+    g.add_legend()
+    _draw_figure(g.figure)
+
+    xfm = g.figure.transFigure.inverted().transform
+    old_pos = xfm(g.legend.legendPatch.get_extents())
+
+    fontsize = 20
+    utils.move_legend(g, "lower left", title_fontsize=fontsize)
+    _draw_figure(g.figure)
+
+    new_pos = xfm(g.legend.legendPatch.get_extents())
+    assert (new_pos < old_pos).all()
+    assert g.legend.get_title().get_text() == hue_var
+    assert g.legend.get_title().get_size() == fontsize
+
+    assert g.legend.legendHandles
+    for i, h in enumerate(g.legend.legendHandles):
+        assert mpl.colors.to_rgb(h.get_color()) == mpl.colors.to_rgb(f"C{i}")
+
+
+def test_move_legend_input_checks():
+
+    ax = plt.figure().subplots()
+    with pytest.raises(TypeError):
+        utils.move_legend(ax.xaxis, "best")
+
+    with pytest.raises(ValueError):
+        utils.move_legend(ax, "best")
+
+    with pytest.raises(ValueError):
+        utils.move_legend(ax.figure, "best")
+
+
 def check_load_dataset(name):
     ds = load_dataset(name, cache=False)
     assert(isinstance(ds, pd.DataFrame))

--- /dev/null
+++ b/doc/docstrings/move_legend.ipynb
@@ -0,0 +1,49 @@
+import seaborn as sns
+
+sns.set_theme()
+
+penguins = sns.load_dataset("penguins")
+For axes-level functions, pass the :class:`matplotlib.axes.Axes` object and provide a new location.
+ax = sns.histplot(penguins, x="bill_length_mm", hue="species")
+
+sns.move_legend(ax, "center right")
+Use the `bbox_to_anchor` parameter for more fine-grained control, including moving the legend outside of the axes:
+ax = sns.histplot(penguins, x="bill_length_mm", hue="species")
+
+sns.move_legend(ax, "upper left", bbox_to_anchor=(1, 1))
+Pass additional :meth:`matplotlib.axes.Axes.legend` parameters to update other properties:
+ax = sns.histplot(penguins, x="bill_length_mm", hue="species")
+
+sns.move_legend(
+
+    ax, "lower center",
+
+    bbox_to_anchor=(.5, 1), ncol=3, title=None, frameon=False,
+
+)
+It's also possible to move the legend created by a figure-level function. But when fine-tuning the position, you must bear in mind that the figure will have extra blank space on the right:
+g = sns.displot(
+
+    penguins,
+
+    x="bill_length_mm", hue="species",
+
+    col="island", col_wrap=2, height=3,
+
+)
+
+sns.move_legend(g, "upper left", bbox_to_anchor=(.55, .45))
+One way to avoid this would be to set `legend_out=False` on the :class:`FacetGrid`:
+g = sns.displot(
+
+    penguins,
+
+    x="bill_length_mm", hue="species",
+
+    col="island", col_wrap=2, height=3,
+
+    facet_kws=dict(legend_out=False),
+
+)
+
+sns.move_legend(g, "upper left", bbox_to_anchor=(.55, .45), frameon=False)

diff --git a/doc/api.rst b/doc/api.rst
index 4d30f9df..86db07d7 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -184,10 +184,11 @@ Utility functions
     :toctree: generated/
     :nosignatures:

-    load_dataset
-    get_dataset_names
-    get_data_home
     despine
-    desaturate
+    move_legend
     saturate
+    desaturate
     set_hls_values
+    load_dataset
+    get_dataset_names
+    get_data_home

diff --git a/doc/docstrings/move_legend.ipynb b/doc/docstrings/move_legend.ipynb
new file mode 100644
index 00000000..705fedbf
--- /dev/null
+++ b/doc/docstrings/move_legend.ipynb
@@ -0,0 +1,156 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8ec46ad8-bc4c-4ee0-9626-271088c702f9",
+   "metadata": {
+    "tags": [
+     "hide"
+    ]
+   },
+   "outputs": [],
+   "source": [
+    "import seaborn as sns\n",
+    "sns.set_theme()\n",
+    "penguins = sns.load_dataset(\"penguins\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "008bdd98-88cb-4a81-9f50-9b0e5a357305",
+   "metadata": {},
+   "source": [
+    "For axes-level functions, pass the :class:`matplotlib.axes.Axes` object and provide a new location."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b82e58f9-b15d-4554-bee5-de6a689344a6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(ax, \"center right\")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "4f2a7f5d-ab39-46c7-87f4-532e607adf0b",
+   "metadata": {},
+   "source": [
+    "Use the `bbox_to_anchor` parameter for more fine-grained control, including moving the legend outside of the axes:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ed610a98-447a-4459-8342-48abc80330f0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(ax, \"upper left\", bbox_to_anchor=(1, 1))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "9d2fd766-a806-45d9-949d-1572991cf512",
+   "metadata": {},
+   "source": [
+    "Pass additional :meth:`matplotlib.axes.Axes.legend` parameters to update other properties:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5ad4342c-c46e-49e9-98a2-6c88c6fb4c54",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = sns.histplot(penguins, x=\"bill_length_mm\", hue=\"species\")\n",
+    "sns.move_legend(\n",
+    "    ax, \"lower center\",\n",
+    "    bbox_to_anchor=(.5, 1), ncol=3, title=None, frameon=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "0d573092-46fd-4a95-b7ed-7e6833823adc",
+   "metadata": {},
+   "source": [
+    "It's also possible to move the legend created by a figure-level function. But when fine-tuning the position, you must bear in mind that the figure will have extra blank space on the right:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b258a9b8-69e5-4d4a-94cb-5b6baddc402b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    penguins,\n",
+    "    x=\"bill_length_mm\", hue=\"species\",\n",
+    "    col=\"island\", col_wrap=2, height=3,\n",
+    ")\n",
+    "sns.move_legend(g, \"upper left\", bbox_to_anchor=(.55, .45))"
+   ]
+  },
+  {
+   "cell_type": "raw",
+   "id": "c9dc54e2-2c66-412f-ab2a-4f2bc2cb5782",
+   "metadata": {},
+   "source": [
+    "One way to avoid this would be to set `legend_out=False` on the :class:`FacetGrid`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "06cff408-4cdf-47af-8def-176f3e70ec5a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g = sns.displot(\n",
+    "    penguins,\n",
+    "    x=\"bill_length_mm\", hue=\"species\",\n",
+    "    col=\"island\", col_wrap=2, height=3,\n",
+    "    facet_kws=dict(legend_out=False),\n",
+    ")\n",
+    "sns.move_legend(g, \"upper left\", bbox_to_anchor=(.55, .45), frameon=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b170f20d-22a9-4f7d-917a-d09e10b1f08c",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "seaborn-py38-latest",
+   "language": "python",
+   "name": "seaborn-py38-latest"
+  },
+  "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.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

diff --git a/doc/releases/v0.11.2.txt b/doc/releases/v0.11.2.txt
index a4181bad..2bd35c84 100644
--- a/doc/releases/v0.11.2.txt
+++ b/doc/releases/v0.11.2.txt
@@ -6,11 +6,13 @@ This is a minor release that addresses issues in the v0.11 series and adds a sma

 - |API| |Enhancement| In :func:`lmplot`, added a new `facet_kws` parameter and deprecated the `sharex`, `sharey`, and `legend_out` parameters from the function signature; pass them in a `facet_kws` dictionary instead (:pr:`2576`).

+- |Feature| Added a :func:`move_legend` convenience function for repositioning the legend on an existing axes or figure, along with updating its properties. This function should be preferred over calling `ax.legend` with no legend data, which does not reliably work across seaborn plot types.
+
 - |Feature| In :func:`histplot`, added `stat="percent"` as an option for normalization such that bar heights sum to 100 and `stat="proportion"` as an alias for the existing `stat="probability"`: (:pr:`2461`, :pr:`2634`).

 - |Feature| Added a ``refline`` method to :class:`FacetGrid` and :class:`JointGrid` for adding horizontal and/or vertical reference lines to every subplot in one step (:pr:`2620`).

-- |Feature| In :func:`kdeplot`, added the `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).
+- |Feature| In :func:`kdeplot`, added a `warn_singular` parameter to silence the warning about data with zero variance (:pr:`2566`).

 - |Enhancement| In :func:`histplot`, improved performance with large datasets and many groupings/facets (:pr:`2559`, :pr:`2570`).

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': 'legendHandles'}, {'type': 'field', 'name': 'legendPatch'}, {'type': 'field', 'name': 'legends'}, {'type': 'field', 'name': 'axisgrid'}]


diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index 3423a79100..822d7ef6f3 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -53,6 +53,8 @@ Other updates

 - |Enhancement| When using :func:`pairplot` with `corner=True` and `diag_kind=None`, the top left y axis label is no longer hidden (:pr:2850`).

+- |Enhancement| Error bars in :func:`regplot` now inherit the alpha value of the points they correspond to (:pr:`2540`).
+
 - |Fix| Fixed a regression in 0.11.2 that caused some functions to stall indefinitely or raise when the input data had a duplicate index (:pr:`2776`).

 - |Fix| Fixed a bug in :func:`histplot` and :func:`kdeplot` where weights were not factored into the normalization (:pr:`2812`).
diff --git a/seaborn/regression.py b/seaborn/regression.py
index d0700f73f7..a6b1087338 100644
--- a/seaborn/regression.py
+++ b/seaborn/regression.py
@@ -396,6 +396,8 @@ def scatterplot(self, ax, kws):
         else:
             # TODO abstraction
             ci_kws = {"color": kws["color"]}
+            if "alpha" in kws:
+                ci_kws["alpha"] = kws["alpha"]
             ci_kws["linewidth"] = mpl.rcParams["lines.linewidth"] * 1.75
             kws.setdefault("s", 50)

diff --git a/tests/test_regression.py b/tests/test_regression.py
index f84c9398a9..280599e673 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -522,6 +522,14 @@ def test_regplot_scatter_kws_alpha(self):
                         scatter_kws={'color': color})
         assert ax.collections[0]._alpha == 0.8

+        f, ax = plt.subplots()
+        alpha = .3
+        ax = lm.regplot(x="x", y="y", data=self.df,
+                        x_bins=5, fit_reg=False,
+                        scatter_kws={"alpha": alpha})
+        for line in ax.lines:
+            assert line.get_alpha() == alpha
+
     def test_regplot_binned(self):

         ax = lm.regplot(x="x", y="y", data=self.df, x_bins=5)

diff --git a/doc/releases/v0.12.0.txt b/doc/releases/v0.12.0.txt
index 3423a791..94c7609d 100644
--- a/doc/releases/v0.12.0.txt
+++ b/doc/releases/v0.12.0.txt
@@ -53,6 +53,8 @@ Other updates

 - |Enhancement| When using :func:`pairplot` with `corner=True` and `diag_kind=None`, the top left y axis label is no longer hidden (:pr:2850`).

+- |Enhancement| Error bars in :func:`regplot` now inherit the alpha value of the points.
+
 - |Fix| Fixed a regression in 0.11.2 that caused some functions to stall indefinitely or raise when the input data had a duplicate index (:pr:`2776`).

 - |Fix| Fixed a bug in :func:`histplot` and :func:`kdeplot` where weights were not factored into the normalization (:pr:`2812`).