diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -29,6 +29,16 @@ impl FigApp {
         }
     }
 
+    /// Clicking the control that opened the colour picker closes it again; clicking another opens
+    /// the picker for that one.
+    pub fn toggle_picker(&mut self, target: colorpicker::PickerTarget, _top: Option<f32>) {
+        if self.ui.picker.as_ref() == Some(&target) {
+            colorpicker::close(self);
+        } else {
+            self.ui.picker = Some(target);
+        }
+    }
+
     /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect
     /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels
     /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more.
diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs
--- a/crates/ui-egui/src/colorpicker_selection_tests.rs
+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs
@@ -53,6 +53,25 @@ fn picker_closes_when_another_layer_is_selected() {
     assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
 }
 
+#[test]
+fn clicking_the_swatch_again_closes_the_picker() {
+    let (mut u, a, _) = two();
+    u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));
+    let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
+    u.app.toggle_picker(target.clone(), Some(200.0));
+    u.frame(vec![]);
+    u.frame(vec![]);
+    assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");
+    u.app.toggle_picker(target.clone(), Some(200.0));
+    u.frame(vec![]);
+    assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");
+    // Another paint of the same layer opens the picker for that one.
+    let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };
+    u.app.toggle_picker(second.clone(), Some(200.0));
+    u.frame(vec![]);
+    assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");
+}
+
 // Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's
 // left edge (only the first row was indented) and ran past its right edge.
 #[test]
@@ -77,11 +96,9 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {
         u.frame(vec![]);
     }
     let out = u.frame_output(vec![]);
-    // The picker is the only floating window open.
-    let picker = u
-        .ctx
-        .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id)))
-        .expect("the picker is open");
+    // The picker is the only floating window open: its area is the picker's own id, which the style
+    // picker also looks up to tell a press in here from one outside.
+    let picker = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("the picker is open");
     // A swatch is a 14 pt square with a 1 pt outline.
     let swatches: Vec<egui::Rect> = out
         .shapes
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -17,6 +17,7 @@ pub mod icons;
 pub mod live;
 pub mod menus;
 pub mod pending_open;
+pub mod popover;
 pub mod present;
 pub mod shortcuts;
 pub mod tabs;
@@ -157,6 +158,8 @@ mod plugin_save_ui_tests;
 #[cfg(test)]
 mod plugins_browse_tests;
 #[cfg(test)]
+mod popover_tests;
+#[cfg(test)]
 mod popup_width_tests;
 #[cfg(test)]
 mod present_links_tests;
@@ -394,6 +397,11 @@ pub struct UiState {
     /// The prototype preview pane floating over the canvas, when open.
     pub preview: Option<present::Pane>,
     pub picker: Option<colorpicker::PickerTarget>,
+    /// The colour picker's popover while it is open (its place survives a drag).
+    pub picker_popover: Option<popover::Popover>,
+    /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):
+    /// a press in it is held out of the popover's closing, so its own click toggles the picker.
+    pub picker_button: Option<egui::Rect>,
     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
     /// focused layer row or the canvas selection and takes the keyboard.
@@ -441,6 +449,8 @@ pub struct UiState {
     pub color_profile: Option<figcraft_color::ColorProfile>,
     /// The open style picker.
     pub style_picker: Option<style_picker::Picker>,
+    /// The style picker's popover while it is open (its place survives a drag).
+    pub style_picker_popover: Option<popover::Popover>,
     /// The style picker shows colour styles as a grid of swatches.
     pub style_grid: bool,
     /// Style folders closed in the style picker.
@@ -570,6 +580,8 @@ impl Default for UiState {
             present_view: present::PresentView::default(),
             preview: None,
             picker: None,
+            picker_popover: None,
+            picker_button: None,
             context_menu: None,
             key_menu: false,
             key_box: None,
@@ -597,6 +609,7 @@ impl Default for UiState {
             pdf_print: figcraft_doc::PrintSettings::default(),
             color_profile: None,
             style_picker: None,
+            style_picker_popover: None,
             style_grid: false,
             style_folds: BTreeSet::new(),
             text_preview: None,
diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs
--- a/crates/ui-egui/src/panel_remove_tests.rs
+++ b/crates/ui-egui/src/panel_remove_tests.rs
@@ -192,3 +192,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() {
     assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));
     assert_eq!(u.app.ui.picker, None);
 }
+
+/// The shared popover's closing reaches the colour picker: a press on the canvas closes it.
+#[test]
+fn a_press_on_the_canvas_closes_the_colour_picker() {
+    let mut s = Session::new();
+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
+    s.execute("select.set", &json!({"ids": [r]})).unwrap();
+    let mut u = Ui::new(s);
+    u.ctx.enable_accesskit();
+    let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();
+    click(&mut u, sw);
+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
+    click(&mut u, Pos2::new(320.0, 300.0));
+    assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker");
+}
+
+/// Pressing the swatch that opened the picker again closes it: its press is held out of the
+/// popover's outside-press closing, so the swatch's own toggle gets the click.
+#[test]
+fn the_swatch_that_opened_the_colour_picker_closes_it_again() {
+    let mut s = Session::new();
+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
+    s.execute("select.set", &json!({"ids": [r]})).unwrap();
+    let mut u = Ui::new(s);
+    u.ctx.enable_accesskit();
+    let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();
+    click(&mut u, sw);
+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
+    click(&mut u, sw);
+    assert_eq!(u.app.ui.picker, None, "the same swatch closes it again");
+}
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -300,11 +300,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
     };
     widgets::section_header(ui, tr("Page"), &[], false);
     let mut open_picker = false;
+    let mut swatch_top = None;
     let mut new_hex = None;
     row(ui, 32.0, |ui| {
         let p = Paint::solid(bg);
-        if widgets::swatch(ui, &p, 16.0).clicked() {
+        let sw = widgets::swatch(ui, &p, 16.0);
+        if sw.clicked() {
             open_picker = true;
+            swatch_top = Some(sw.rect.min.y);
         }
         if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") {
             new_hex = Some(h);
@@ -312,7 +315,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
         widgets::label(ui, "100 %", t.text);
     });
     if open_picker {
-        app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);
+        app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top);
     }
     if let Some(h) = new_hex
         && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs
--- a/crates/ui-egui/src/panels/right/effects.rs
+++ b/crates/ui-egui/src/panels/right/effects.rs
@@ -325,8 +325,9 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect {
 /// duo noise's second colour), in the current row; `edit` applies a new one.
 fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) {
     let Some(c) = e.color(secondary) else { return };
-    if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() {
-        app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });
+    let sw = widgets::swatch(ui, &Paint::solid(c), 16.0);
+    if sw.clicked() {
+        app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y));
     }
     if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")
         && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)
diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs
--- a/crates/ui-egui/src/panels/right/paints.rs
+++ b/crates/ui-egui/src/panels/right/paints.rs
@@ -248,8 +248,9 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:
         match fills.as_ref().and_then(|f| f.first()) {
             Some(p) => {
                 widgets::paint_swatch(ui.painter(), sw, p);
-                if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() {
-                    app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
+                let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click());
+                if range_swatch.clicked() {
+                    app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y));
                 }
                 if let Some(c) = p.solid_color() {
                     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
@@ -437,8 +438,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
         let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
         widgets::paint_swatch(ui.painter(), sw, p);
         let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click());
+        // While this paint is the one the picker edits, its swatch is held out of the popover's
+        // closing, so a press on it again reaches the toggle below.
+        let editing = matches!(
+            &app.ui.picker,
+            Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids
+        );
+        if editing {
+            app.ui.picker_button = Some(sresp.rect);
+        }
         if sresp.clicked() {
-            app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() });
+            app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y));
         }
         let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
         let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs
--- a/crates/ui-egui/src/panels/selection_colors.rs
+++ b/crates/ui-egui/src/panels/selection_colors.rs
@@ -47,9 +47,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti
     colors
 }
 
-pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {
+pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, top: Option<f32>) {
     let Ok(ds) = app.session.ds() else { return };
-    app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
+    app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top);
 }
 
 /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
@@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
                     crate::right::row(ui, 32.0, |ui| {
                         let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
                         if swatch.clicked() {
-                            open_picker(app, group.targets.clone());
+                            toggle(app, group.targets.clone(), Some(swatch.rect.min.y));
                         }
                         let width = (ui.available_width() - 116.0).max(40.0);
                         let linked_name = group
@@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
                             .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));
                         if let Some(name) = linked_name {
                             if widgets::dropdown(ui, name, width, None).clicked() {
-                                open_picker(app, group.targets.clone());
+                                toggle(app, group.targets.clone(), None);
                             }
                         } else if let Some(color) = group.paint.solid_color() {
                             if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {
@@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
                         } else {
                             let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
                             if response.clicked() {
-                                open_picker(app, group.targets.clone());
+                                toggle(app, group.targets.clone(), Some(response.rect.min.y));
                             }
                         }
                         let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/popover.rs
@@ -0,0 +1,283 @@
+//! The chrome every detached panel shares: a card with a header — the title 16 points in from its
+//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup
+//! shadow, around one slot.
+//!
+//! The measurements are our own: they are the header the colour picker has always drawn, and the
+//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing
+//! here is taken from another product's interface.
+//!
+//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off
+//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on
+//! Escape and on a press outside it — except in the rects the caller holds out (the button that
+//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead
+//! of the press closing it and the release opening it again.
+
+use egui::epaint::RectShape;
+use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2};
+
+use crate::a11y::Tip;
+use crate::i18n::tr;
+use crate::icons::{self, Icon};
+use crate::theme::{Tokens, semibold};
+use crate::widgets;
+
+/// The default width: the colour picker's, and the one every detached panel shares.
+pub const WIDTH: f32 = 240.0;
+/// The header's height: the title's line and the X sit in it.
+pub const HEADER_H: f32 = 40.0;
+/// The title starts this far from the panel's left edge, the X ends this far from its right one.
+const TITLE_LEFT: f32 = 16.0;
+const CLOSE_RIGHT: f32 = 8.0;
+/// The X's button (the icon is inset in it).
+const CLOSE: f32 = 24.0;
+/// The slot's default margin inside the panel.
+const SLOT_PAD: f32 = 16.0;
+/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others.
+const SCREEN_GAP: f32 = 16.0;
+const EDGE: f32 = 8.0;
+/// The panel's corners.
+const RADIUS: u8 = 13;
+
+/// The surface a popover draws on: the panels' own, or the dark one the style picker uses.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Surface {
+    Panel,
+    Menu,
+}
+
+/// Where a popover opens when the caller has not put it down itself.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Open {
+    /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),
+    /// lifted when that would run its bottom past the screen's bottom.
+    Anchor(Pos2),
+    /// Its top-left exactly here.
+    At(Pos2),
+}
+
+/// What keeps a popover open while a press or Escape happens somewhere else.
+#[derive(Clone, Copy, Default)]
+pub struct Hold<'a> {
+    /// Presses in these rects do not close it: the button that opened it, the popovers it opened.
+    pub rects: &'a [Rect],
+    /// Escape belongs to something above it (a field being typed in, a picker of its own).
+    pub escape: bool,
+}
+
+/// Where the popover shows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Place {
+    /// From [`Open`], until the header drags it.
+    Auto,
+    /// Here: put down by the caller or by the drag.
+    At(Pos2),
+}
+
+/// An open popover.
+pub struct Popover {
+    id: Id,
+    /// The header's line.
+    pub title: String,
+    width: f32,
+    pad: f32,
+    surface: Surface,
+    /// Whether a press outside it closes it.
+    outside: bool,
+    place: Place,
+    /// While the header is held down: where in the panel the pointer grabbed it.
+    grab: Option<Vec2>,
+    /// The panel, its header, its slot and the X, as drawn last frame.
+    pub rect: Rect,
+    pub header: Rect,
+    pub slot: Rect,
+    pub close: Rect,
+    /// The panel's size as laid out last frame.
+    size: Vec2,
+}
+
+impl Popover {
+    /// A popover titled `title`; `id` names its egui area, so it must be stable and unique.
+    pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
+        Self::with_id(Id::unique(id), title)
+    }
+
+    /// A popover whose egui area is `id` itself — not a hash of it — so a caller that publishes the
+    /// id can find the area again (the colour picker's [`crate::colorpicker::window_id`]).
+    pub fn with_id(id: Id, title: impl Into<String>) -> Self {
+        Self {
+            id,
+            title: title.into(),
+            width: WIDTH,
+            pad: SLOT_PAD,
+            surface: Surface::Panel,
+            outside: true,
+            place: Place::Auto,
+            grab: None,
+            rect: Rect::NOTHING,
+            header: Rect::NOTHING,
+            slot: Rect::NOTHING,
+            close: Rect::NOTHING,
+            size: vec2(WIDTH, 0.0),
+        }
+    }
+
+    /// `width` points wide instead of [`WIDTH`].
+    pub fn width(mut self, width: f32) -> Self {
+        self.width = width.max(1.0);
+        self.size.x = self.width;
+        self
+    }
+
+    /// `pad` points of margin around the slot instead of the default 16.
+    pub fn pad(mut self, pad: f32) -> Self {
+        self.pad = pad.max(0.0);
+        self
+    }
+
+    /// Drawn on the dark surface the style browser uses.
+    pub fn surface(mut self, surface: Surface) -> Self {
+        self.surface = surface;
+        self
+    }
+
+    /// Whether a press outside it closes it (the default). The colour picker stays open, so a
+    /// press on a row beside it keeps editing the paint it is on.
+    pub fn close_on_outside(mut self, yes: bool) -> Self {
+        self.outside = yes;
+        self
+    }
+
+    /// Its top-left at `pos`, instead of where [`Open`] would put it.
+    pub fn at(mut self, pos: Pos2) -> Self {
+        self.place = Place::At(pos);
+        self
+    }
+
+    /// Draws the panel and fills its slot with `body`; true when it asked to close — the X,
+    /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame.
+    pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool {
+        let t = Tokens::get(ctx);
+        let dark = self.surface == Surface::Menu;
+        let screen = ctx.content_rect();
+        // The anchor rule needs the panel's height, which only a layout gives: the first frame
+        // lays it out invisibly and the next one places it — a jump would show.
+        let first = self.size.y <= 0.0;
+        let pos = match self.place {
+            Place::At(p) => clamp(p, self.size, screen),
+            Place::Auto => {
+                let p = match open {
+                    Open::At(p) => p,
+                    Open::Anchor(a) => open_pos(a, self.size, screen),
+                };
+                clamp(p, self.size, screen)
+            }
+        };
+        let mut close = false;
+        egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
+            if first {
+                ui.set_invisible();
+                ctx.request_repaint();
+            }
+            // The panel is painted into a place kept before the content: the shadow and the fill
+            // go under it, though its size is only known once the slot is laid out.
+            let bg = ui.painter().add(egui::Shape::Noop);
+            let spacing = ui.spacing().item_spacing;
+            ui.spacing_mut().item_spacing = Vec2::ZERO;
+            // 1. The header: the title, then the X at the right.
+            let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover());
+            let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE));
+            let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y));
+            let drag = ui.interact(handle, self.id.with("drag"), Sense::drag());
+            let font = semibold(11.0);
+            let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone());
+            ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark));
+            close = close_button(ui, self.id.with("close"), close_rect, dark, t);
+            // 2. The header drags the panel (grabbed where the press landed, so the first frame of
+            // a fast drag counts too); the X keeps its own clicks.
+            if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) {
+                self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos);
+            }
+            if drag.dragged()
+                && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos())
+            {
+                self.place = Place::At(clamp(at - grab, self.size, screen));
+            }
+            if ctx.input(|i| i.pointer.any_released()) {
+                self.grab = None;
+            }
+            if drag.dragged() || drag.hovered() {
+                ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
+            }
+            // 3. The slot, under a hairline.
+            let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| {
+                ui.spacing_mut().item_spacing = spacing;
+                ui.set_width(self.width - 2.0 * self.pad);
+                body(ui);
+                ui.min_rect()
+            });
+            let slot = frame.inner;
+            let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y));
+            let shadow = ctx.global_style().visuals.popup_shadow;
+            let fill = if dark { t.menu } else { t.panel };
+            let shapes = vec![
+                egui::Shape::from(shadow.as_shape(panel, RADIUS)),
+                RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(),
+                // A point of the theme's border colour under the header.
+                RectShape::filled(
+                    Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)),
+                    CornerRadius::ZERO,
+                    if dark { Color32::from_white_alpha(24) } else { t.border },
+                )
+                .into(),
+            ];
+            ui.painter().set(bg, egui::Shape::Vec(shapes));
+            self.rect = panel;
+            self.header = header;
+            self.slot = slot;
+            self.close = close_rect;
+            self.size = panel.size();
+        });
+        // Closing is the widget's: the X above, Escape and a press outside (a child popup or a
+        // held-out rect keeps it open, so the button that opened it can click it shut).
+        let popup_open = egui::Popup::is_any_open(ctx);
+        let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape));
+        let outside = self.outside
+            && !popup_open
+            && ctx.input(|i| {
+                i.pointer.any_pressed()
+                    && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at)))
+            });
+        close || escape || outside
+    }
+}
+
+fn header_text(t: Tokens, dark: bool) -> Color32 {
+    if dark { t.menu_text } else { t.text }
+}
+
+/// The X: a 24-point button, its icon inset 6 (the colour picker's header).
+fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {
+    let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));
+    if resp.hovered() {
+        ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });
+    }
+    icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });
+    resp.clicked()
+}
+
+/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on
+/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to
+/// keep [`SCREEN_GAP`] under it.
+fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
+    let bottom = screen.max.y - SCREEN_GAP;
+    let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y };
+    pos2(anchor.x - size.x, y)
+}
+
+/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.
+fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {
+    let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);
+    let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0));
+    let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0));
+    pos2(x, y)
+}
diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/popover_tests.rs
@@ -0,0 +1,175 @@
+//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in
+//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule
+//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen),
+//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press
+//! in a held-out rect, so the button that opened it can toggle it shut.
+
+use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
+
+use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH};
+
+const SCREEN: egui::Vec2 = vec2(1440.0, 900.0);
+
+/// A context with the UI's fonts: the header's title is drawn in the semibold family.
+fn context() -> egui::Context {
+    let ctx = egui::Context::default();
+    crate::theme::install_fonts(&ctx, false, 0);
+    ctx
+}
+
+fn input(t: &mut f64, events: Vec<Event>) -> RawInput {
+    *t += 1.0 / 60.0;
+    RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() }
+}
+
+fn press(p: Pos2, pressed: bool) -> Vec<Event> {
+    vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]
+}
+
+fn escape() -> Vec<Event> {
+    vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]
+}
+
+/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it
+/// asked to close.
+fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec<Event>) -> (Vec<egui::Shape>, bool) {
+    let mut closed = false;
+    let mut out = ctx.run_ui(input(t, events), |ui| {
+        closed = p.show(ui.ctx(), open, hold, |ui| {
+            ui.label("slot");
+        });
+    });
+    out.textures_delta.clear();
+    (crate::live_tests::flat(&out.shapes), closed)
+}
+
+/// Two frames: the first lays the panel out to learn its height, the second places and paints it.
+fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec<egui::Shape> {
+    draw(ctx, t, p, open, Hold::default(), vec![]);
+    draw(ctx, t, p, open, Hold::default(), vec![]).0
+}
+
+fn near(a: Pos2, b: Pos2) -> bool {
+    (a - b).length() < 0.05
+}
+
+#[test]
+fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_header", "Custom");
+    let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+    assert_eq!(p.rect.width(), WIDTH, "240 points wide");
+    assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect);
+    assert_eq!(p.header.height(), HEADER_H, "the header is 40 high");
+    // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line.
+    assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close);
+    assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line");
+    // The title: one line, 16 in from the left, on the header's middle.
+    let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows");
+    assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}");
+    assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}");
+    // The slot: 16 in from the panel's sides, under the header.
+    assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot);
+    assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot);
+}
+
+#[test]
+fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_anchor", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0)));
+    assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect);
+    assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
+}
+
+#[test]
+fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_lift", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0)));
+    // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's.
+    assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect);
+    assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect);
+}
+
+#[test]
+fn the_header_drags_it_and_the_slot_does_not() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_drag", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+    let at = Open::At(pos2(160.0, 100.0));
+    let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0));
+    let before = p.rect.min;
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+    assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min);
+    // The slot does not drag it.
+    let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0));
+    let held = p.rect.min;
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+    assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min);
+}
+
+#[test]
+fn the_x_and_escape_close_it() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_close", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+    let at = Open::At(pos2(160.0, 100.0));
+    let close = p.close.center();
+    assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet");
+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close");
+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close");
+    // Escape that belongs to something above it does not.
+    let mut p = Popover::new("popover_escape_held", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+    let hold = Hold { rects: &[], escape: true };
+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open");
+}
+
+#[test]
+fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() {
+    let ctx = context();
+    let mut t = 0.0;
+    let mut p = Popover::new("popover_outside", "Popup");
+    settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0)));
+    let at = Open::At(pos2(300.0, 200.0));
+    // The button that opened it: a press there leaves it open, so its release can toggle it shut.
+    let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0));
+    let hold = Hold { rects: &[opener], escape: false };
+    let on_opener = opener.center();
+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open");
+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release");
+    // A press anywhere else closes it, before the release.
+    let elsewhere = pos2(900.0, 400.0);
+    assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it");
+}
+
+#[test]
+fn a_caller_can_put_it_where_it_likes() {
+    let ctx = context();
+    let mut t = 0.0;
+    // Placed outright, the opening rule no longer matters.
+    let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0));
+    settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0)));
+    assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect);
+    // Nor after a drag: it stays where it was dropped.
+    let at = Open::At(pos2(900.0, 100.0));
+    let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+    assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);
+}
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -454,6 +454,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
     if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
         return true;
     }
+    // The colour picker open: Escape closes it here, before the binding deselects — unless one of
+    // its own dropdowns is open, which takes this Escape.
+    if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
+        return true;
+    }
     if key_box(app, key, m) {
         return true;
     }
diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs
--- a/crates/ui-egui/src/widgets/colorpicker.rs
+++ b/crates/ui-egui/src/widgets/colorpicker.rs
@@ -1,7 +1,7 @@
 //! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders,
 //! hex and RGB fields, gradient stops, image and pattern fills and document colours.
 
-use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
+use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
 use figcraft_color::{
     Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile,
 };
@@ -12,7 +12,8 @@ use crate::FigApp;
 use crate::a11y::Tip;
 use crate::i18n::{tr, tr_noop, trf};
 use crate::icons::{self, Icon};
-use crate::theme::{Tokens, doc_c32, regular, semibold};
+use crate::popover::{Hold, Open, Popover};
+use crate::theme::{Tokens, doc_c32, regular};
 use crate::widgets;
 
 #[derive(Clone, Debug, PartialEq)]
@@ -568,6 +569,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
             finish(app);
         }
         app.ui.picker = None;
+        app.ui.picker_popover = None;
         return;
     };
     let t = Tokens::get(ctx);
@@ -578,413 +580,427 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         _ => None,
     };
     let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0));
-    let mut open = true;
-    let mut close_now = false;
-    egui::Window::new("color_picker")
-        // A fixed area id so the style picker can tell a click in here from a click outside
-        // (`Window::new` alone would key the area by `Id::unique(Some(title))`).
-        .id(window_id())
-        .title_bar(false)
-        .fixed_pos(pos)
-        .fixed_size(vec2(240.0, 0.0))
-        .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))
-        .open(&mut open)
-        .show(ctx, |ui| {
-            ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
-            // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
-            if let Some(c) = app.session.tool.picked.take() {
-                set_color(app, &target, &mut paint, c, stop_id(), ui);
-            }
-            // Header.
-            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());
-            ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);
-            let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));
-            let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click());
-            icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon);
-            if cresp.clicked() {
-                close_now = true;
-            }
-            // Paint type.
-            if !matches!(
-                target,
-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
-            ) {
-                let kinds = [
-                    (Icon::Fill, tr("Solid")),
-                    (Icon::GradientLinear, tr("Linear")),
-                    (Icon::GradientRadial, tr("Radial")),
-                    (Icon::GradientAngular, tr("Angular")),
-                    (Icon::GradientDiamond, tr("Diamond")),
-                    (Icon::Image, tr("Image")),
-                    (Icon::ViewGrid, tr("Pattern")),
-                ];
-                let active = match &paint.kind {
-                    PaintKind::Solid { .. } => 0,
-                    PaintKind::Gradient(g) => match g.kind {
-                        GradientKind::Linear => 1,
-                        GradientKind::Radial => 2,
-                        GradientKind::Angular => 3,
-                        GradientKind::Diamond => 4,
-                    },
-                    PaintKind::Image(_) => 5,
-                    PaintKind::Pattern(_) => 6,
-                };
-                ui.horizontal(|ui| {
-                    ui.add_space(12.0);
-                    if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
-                        let base = paint.preview_color();
-                        let mut image_pending = false;
-                        paint.kind = match i {
-                            0 => PaintKind::Solid { color: base.with_alpha(1.0) },
-                            5 => {
-                                image_pending = true;
-                                let target = target.clone();
-                                app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
-                                    let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
-                                    let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
-                                    if let Some(mut paint) = current_paint(app, &target) {
-                                        paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
-                                        write(app, &target, &paint, false);
-                                    }
-                                });
+    // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared
+    // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the
+    // style picker looks up to tell a press in here from one outside.
+    let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::with_id(window_id(), tr("Custom")).pad(0.0));
+    // Sampling a colour from the canvas — the eyedropper this picker's own button started, or the
+    // colour it just sampled — is the press, not a press outside: neither may close the picker.
+    let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();
+    pop = pop.close_on_outside(!picking);
+    // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel
+    // beside it keep it (swatches and fields there switch the colour it edits); the control that
+    // opened it is held too, so pressing it again reaches its own toggle.
+    let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max);
+    let mut held: Vec<Rect> = vec![panel];
+    held.extend(app.ui.picker_button);
+    let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| {
+        ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
+        // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
+        if let Some(c) = app.session.tool.picked.take() {
+            set_color(app, &target, &mut paint, c, stop_id(), ui);
+        }
+        // Paint type.
+        if !matches!(
+            target,
+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
+        ) {
+            let kinds = [
+                (Icon::Fill, tr("Solid")),
+                (Icon::GradientLinear, tr("Linear")),
+                (Icon::GradientRadial, tr("Radial")),
+                (Icon::GradientAngular, tr("Angular")),
+                (Icon::GradientDiamond, tr("Diamond")),
+                (Icon::Image, tr("Image")),
+                (Icon::ViewGrid, tr("Pattern")),
+            ];
+            let active = match &paint.kind {
+                PaintKind::Solid { .. } => 0,
+                PaintKind::Gradient(g) => match g.kind {
+                    GradientKind::Linear => 1,
+                    GradientKind::Radial => 2,
+                    GradientKind::Angular => 3,
+                    GradientKind::Diamond => 4,
+                },
+                PaintKind::Image(_) => 5,
+                PaintKind::Pattern(_) => 6,
+            };
+            ui.horizontal(|ui| {
+                ui.add_space(12.0);
+                if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
+                    let base = paint.preview_color();
+                    let mut image_pending = false;
+                    paint.kind = match i {
+                        0 => PaintKind::Solid { color: base.with_alpha(1.0) },
+                        5 => {
+                            image_pending = true;
+                            let target = target.clone();
+                            app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
+                                let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
+                                let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
+                                if let Some(mut paint) = current_paint(app, &target) {
+                                    paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
+                                    write(app, &target, &paint, false);
+                                }
+                            });
+                            paint.kind.clone()
+                        }
+                        6 => match pattern_sources(app, &target).first() {
+                            Some((id, _)) => Paint::pattern(id.0).kind,
+                            None => {
+                                app.toast(tr("Add a layer or component to tile first").to_string());
                                 paint.kind.clone()
                             }
-                            6 => match pattern_sources(app, &target).first() {
-                                Some((id, _)) => Paint::pattern(id.0).kind,
-                                None => {
-                                    app.toast(tr("Add a layer or component to tile first").to_string());
-                                    paint.kind.clone()
-                                }
-                            },
-                            k => {
-                                let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
-                                match &paint.kind {
-                                    PaintKind::Gradient(g) => {
-                                        PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
-                                    }
-                                    _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
+                        },
+                        k => {
+                            let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
+                            match &paint.kind {
+                                PaintKind::Gradient(g) => {
+                                    PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
                                 }
+                                _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
                             }
-                        };
-                        if !image_pending {
-                            write(app, &target, &paint, false);
                         }
+                    };
+                    if !image_pending {
+                        write(app, &target, &paint, false);
                     }
-                });
-            }
-            // The paint's own blend mode, with the paints and layers under it.
-            if !matches!(
-                target,
-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
-            ) {
-                ui.horizontal(|ui| {
-                    ui.add_space(16.0);
-                    let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
-                    crate::right::menu_popup(&resp).width(200.0).show(|ui| {
-                        if let Some(b) = crate::right::blend_menu(ui, false) {
-                            paint.blend = b;
-                            write(app, &target, &paint, false);
-                        }
-                    });
-                });
-            }
-            // Gradient stops.
-            let mut edit_color: Option<Color> = match &paint.kind {
-                PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
-                _ => None,
-            };
-            let sel_stop_id = stop_id();
-            if let PaintKind::Gradient(g) = &paint.kind {
-                let mut g = g.clone();
-                let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
-                let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
-                let bar = bar.shrink2(vec2(16.0, 6.0));
-                widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
-                let n = 24;
-                for i in 0..n {
-                    let a = i as f32 / n as f32;
-                    let b = (i + 1) as f32 / n as f32;
-                    let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
-                    ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
                 }
-                let mut changed = false;
-                let mut done = false;
-                // The bar first, so the stops on it take their own clicks.
-                let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
-                for (i, s) in g.stops.clone().iter().enumerate() {
-                    let x = bar.min.x + s.position * bar.width();
-                    let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
-                    let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
-                    ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
-                    ui.painter().rect_stroke(
-                        r,
-                        CornerRadius::same(3),
-                        Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
-                        StrokeKind::Outside,
-                    );
-                    if resp.clicked() || resp.drag_started() {
-                        sel = i;
-                    }
-                    if resp.dragged()
-                        && let Some(p) = resp.interact_pointer_pos()
-                        && let Some(st) = g.stops.get_mut(i)
-                    {
-                        st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
-                        changed = true;
-                    }
-                    if resp.drag_stopped() {
-                        done = true;
+            });
+        }
+        // The paint's own blend mode, with the paints and layers under it.
+        if !matches!(
+            target,
+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
+        ) {
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
+                crate::right::menu_popup(&resp).width(200.0).show(|ui| {
+                    if let Some(b) = crate::right::blend_menu(ui, false) {
+                        paint.blend = b;
+                        write(app, &target, &paint, false);
                     }
+                });
+            });
+        }
+        // Gradient stops.
+        let mut edit_color: Option<Color> = match &paint.kind {
+            PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
+            _ => None,
+        };
+        let sel_stop_id = stop_id();
+        if let PaintKind::Gradient(g) = &paint.kind {
+            let mut g = g.clone();
+            let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
+            let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
+            let bar = bar.shrink2(vec2(16.0, 6.0));
+            widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
+            let n = 24;
+            for i in 0..n {
+                let a = i as f32 / n as f32;
+                let b = (i + 1) as f32 / n as f32;
+                let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
+                ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
+            }
+            let mut changed = false;
+            let mut done = false;
+            // The bar first, so the stops on it take their own clicks.
+            let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
+            for (i, s) in g.stops.clone().iter().enumerate() {
+                let x = bar.min.x + s.position * bar.width();
+                let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
+                let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
+                ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
+                ui.painter().rect_stroke(
+                    r,
+                    CornerRadius::same(3),
+                    Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
+                    StrokeKind::Outside,
+                );
+                if resp.clicked() || resp.drag_started() {
+                    sel = i;
                 }
-                // Click on the bar adds a stop.
-                if bresp.clicked()
-                    && let Some(p) = bresp.interact_pointer_pos()
+                if resp.dragged()
+                    && let Some(p) = resp.interact_pointer_pos()
+                    && let Some(st) = g.stops.get_mut(i)
                 {
-                    let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
-                    g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
-                    sel = g.stops.len() - 1;
+                    st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
                     changed = true;
+                }
+                if resp.drag_stopped() {
                     done = true;
                 }
-                ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
-                if changed {
+            }
+            // Click on the bar adds a stop.
+            if bresp.clicked()
+                && let Some(p) = bresp.interact_pointer_pos()
+            {
+                let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
+                g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
+                sel = g.stops.len() - 1;
+                changed = true;
+                done = true;
+            }
+            ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
+            if changed {
+                paint.kind = PaintKind::Gradient(g.clone());
+                write(app, &target, &paint, !done);
+            }
+            if done {
+                finish(app);
+            }
+            edit_color = g.stops.get(sel).map(|s| s.color);
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
+                    g.stops.remove(sel);
+                    ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
+                    paint.kind = PaintKind::Gradient(g.clone());
+                    write(app, &target, &paint, false);
+                }
+                if ui.small_button(tr("Flip")).clicked() {
+                    for s in &mut g.stops {
+                        s.position = 1.0 - s.position;
+                    }
                     paint.kind = PaintKind::Gradient(g.clone());
-                    write(app, &target, &paint, !done);
+                    write(app, &target, &paint, false);
                 }
-                if done {
-                    finish(app);
+                if ui.small_button(tr("Rotate 90°")).clicked() {
+                    let (fx, fy) = g.from;
+                    let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
+                    g.to = rot(g.to);
+                    g.width = rot(g.width);
+                    paint.kind = PaintKind::Gradient(g.clone());
+                    write(app, &target, &paint, false);
                 }
-                edit_color = g.stops.get(sel).map(|s| s.color);
+            });
+            // The selected stop's colour can come from a colour variable.
+            if let PickerTarget::Paint { fills, index, ids } = &target {
+                let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
+                let stop = g.stops.get(sel).map(|s| s.color);
                 ui.horizontal(|ui| {
                     ui.add_space(16.0);
-                    if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
-                        g.stops.remove(sel);
-                        ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
-                        paint.kind = PaintKind::Gradient(g.clone());
-                        write(app, &target, &paint, false);
-                    }
-                    if ui.small_button(tr("Flip")).clicked() {
-                        for s in &mut g.stops {
-                            s.position = 1.0 - s.position;
+                    crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
+                        let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
+                        ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
+                        let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
+                        if let Some(c) = stop {
+                            widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
+                            let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
+                            ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
+                        }
+                    });
+                });
+            }
+        }
+        if let PaintKind::Image(img) = &paint.kind {
+            let mut img = img.clone();
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
+                crate::right::menu_popup(&resp).show(|ui| {
+                    for (l, m) in [
+                        (tr("Fill"), ImageScaleMode::Fill),
+                        (tr("Fit"), ImageScaleMode::Fit),
+                        (tr("Crop"), ImageScaleMode::Crop),
+                        (tr("Tile"), ImageScaleMode::Tile),
+                    ] {
+                        if crate::right::menu_item(ui, l, "") {
+                            img.scale_mode = m;
+                            paint.kind = PaintKind::Image(img.clone());
+                            write(app, &target, &paint, false);
+                            ui.close();
                         }
-                        paint.kind = PaintKind::Gradient(g.clone());
-                        write(app, &target, &paint, false);
-                    }
-                    if ui.small_button(tr("Rotate 90°")).clicked() {
-                        let (fx, fy) = g.from;
-                        let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
-                        g.to = rot(g.to);
-                        g.width = rot(g.width);
-                        paint.kind = PaintKind::Gradient(g.clone());
-                        write(app, &target, &paint, false);
                     }
                 });
-                // The selected stop's colour can come from a colour variable.
-                if let PickerTarget::Paint { fills, index, ids } = &target {
-                    let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
-                    let stop = g.stops.get(sel).map(|s| s.color);
-                    ui.horizontal(|ui| {
-                        ui.add_space(16.0);
-                        crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
-                            let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
-                            ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
-                            let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
-                            if let Some(c) = stop {
-                                widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
-                                let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
-                                ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
-                            }
-                        });
-                    });
+                if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
+                    img.rotation = (img.rotation + 1) % 4;
+                    paint.kind = PaintKind::Image(img.clone());
+                    write(app, &target, &paint, false);
                 }
-            }
-            if let PaintKind::Image(img) = &paint.kind {
-                let mut img = img.clone();
+            });
+            // The image's own colour profile (decoding converts it into the file's).
+            let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
+            let source = match source.as_deref() {
+                None => tr("Unknown — treated as sRGB"),
+                Some("") => tr("Embedded profile"),
+                Some(name) => name,
+            };
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                let text = trf("Color profile: {name}", &[("name", &source)]);
+                ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
+            });
+            image_presets(app, ui, &target, &mut paint, img.filters);
+            for (label, val) in [
+                (tr_noop("Exposure"), img.filters.exposure),
+                (tr_noop("Contrast"), img.filters.contrast),
+                (tr_noop("Saturation"), img.filters.saturation),
+                (tr_noop("Temperature"), img.filters.temperature),
+                (tr_noop("Tint"), img.filters.tint),
+                (tr_noop("Highlights"), img.filters.highlights),
+                (tr_noop("Shadows"), img.filters.shadows),
+            ] {
                 ui.horizontal(|ui| {
                     ui.add_space(16.0);
-                    let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
-                    crate::right::menu_popup(&resp).show(|ui| {
-                        for (l, m) in [
-                            (tr("Fill"), ImageScaleMode::Fill),
-                            (tr("Fit"), ImageScaleMode::Fit),
-                            (tr("Crop"), ImageScaleMode::Crop),
-                            (tr("Tile"), ImageScaleMode::Tile),
-                        ] {
-                            if crate::right::menu_item(ui, l, "") {
-                                img.scale_mode = m;
-                                paint.kind = PaintKind::Image(img.clone());
-                                write(app, &target, &paint, false);
-                                ui.close();
-                            }
+                    // Labels share a column so the sliders line up.
+                    let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
+                    ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
+                    let mut v = val;
+                    let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
+                    if r.changed() {
+                        let f = &mut img.filters;
+                        match label {
+                            "Exposure" => f.exposure = v,
+                            "Contrast" => f.contrast = v,
+                            "Saturation" => f.saturation = v,
+                            "Temperature" => f.temperature = v,
+                            "Tint" => f.tint = v,
+                            "Highlights" => f.highlights = v,
+                            _ => f.shadows = v,
                         }
-                    });
-                    if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
-                        img.rotation = (img.rotation + 1) % 4;
                         paint.kind = PaintKind::Image(img.clone());
-                        write(app, &target, &paint, false);
+                        write(app, &target, &paint, !r.drag_stopped());
+                    }
+                    if r.drag_stopped() {
+                        finish(app);
                     }
                 });
-                // The image's own colour profile (decoding converts it into the file's).
-                let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
-                let source = match source.as_deref() {
-                    None => tr("Unknown — treated as sRGB"),
-                    Some("") => tr("Embedded profile"),
-                    Some(name) => name,
-                };
-                ui.horizontal(|ui| {
-                    ui.add_space(16.0);
-                    let text = trf("Color profile: {name}", &[("name", &source)]);
-                    ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
-                });
-                image_presets(app, ui, &target, &mut paint, img.filters);
-                for (label, val) in [
-                    (tr_noop("Exposure"), img.filters.exposure),
-                    (tr_noop("Contrast"), img.filters.contrast),
-                    (tr_noop("Saturation"), img.filters.saturation),
-                    (tr_noop("Temperature"), img.filters.temperature),
-                    (tr_noop("Tint"), img.filters.tint),
-                    (tr_noop("Highlights"), img.filters.highlights),
-                    (tr_noop("Shadows"), img.filters.shadows),
-                ] {
-                    ui.horizontal(|ui| {
-                        ui.add_space(16.0);
-                        // Labels share a column so the sliders line up.
-                        let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
-                        ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
-                        let mut v = val;
-                        let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
-                        if r.changed() {
-                            let f = &mut img.filters;
-                            match label {
-                                "Exposure" => f.exposure = v,
-                                "Contrast" => f.contrast = v,
-                                "Saturation" => f.saturation = v,
-                                "Temperature" => f.temperature = v,
-                                "Tint" => f.tint = v,
-                                "Highlights" => f.highlights = v,
-                                _ => f.shadows = v,
-                            }
-                            paint.kind = PaintKind::Image(img.clone());
-                            write(app, &target, &paint, !r.drag_stopped());
-                        }
-                        if r.drag_stopped() {
-                            finish(app);
-                        }
-                    });
-                }
             }
-            if let PaintKind::Pattern(pt) = paint.kind.clone() {
-                pattern_controls(app, ui, &target, &mut paint, pt);
+        }
+        if let PaintKind::Pattern(pt) = paint.kind.clone() {
+            pattern_controls(app, ui, &target, &mut paint, pt);
+        }
+        if let Some(color) = edit_color {
+            let mut hsb = ui
+                .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))
+                .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())
+                .map(|(h, s, b, _)| Hsb { h, s, b })
+                .unwrap_or_else(|| Hsb::from_color(color));
+            let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());
+            let (c1, d1) = sv_square(ui, sq, &mut hsb);
+            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
+            let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));
+            if icons::button(
+                &mut ui
+                    .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))),
+                Icon::Eyedropper,
+                24.0,
+                false,
+                tr("Pick color (I)"),
+            )
+            .clicked()
+            {
+                app.run("edit.pickColor", json!({"apply": false}));
+                return;
             }
-            if let Some(color) = edit_color {
-                let mut hsb = ui
-                    .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))
-                    .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())
-                    .map(|(h, s, b, _)| Hsb { h, s, b })
-                    .unwrap_or_else(|| Hsb::from_color(color));
-                let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());
-                let (c1, d1) = sv_square(ui, sq, &mut hsb);
-                let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
-                let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));
-                if icons::button(
-                    &mut ui.new_child(
-                        egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))),
-                    ),
-                    Icon::Eyedropper,
-                    24.0,
-                    false,
-                    tr("Pick color (I)"),
-                )
-                .clicked()
-                {
-                    app.run("edit.pickColor", json!({"apply": false}));
-                    return;
-                }
-                let (c2, d2) = hue_slider(ui, hr, &mut hsb);
-                let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());
-                let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));
-                let mut new_alpha = None;
-                if let Some((a, d)) = alpha_slider(ui, ar, color) {
-                    new_alpha = Some((a, d));
-                }
-                let mut nc = hsb.to_color().with_alpha(color.a);
-                let mut done = d1 || d2;
-                let mut changed = c1 || c2;
-                if let Some((a, d)) = new_alpha {
-                    nc = nc.with_alpha(a);
-                    changed = true;
-                    done |= d;
-                }
-                // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.
-                if let Some((c, d)) = model_fields(ui, nc) {
-                    nc = c;
-                    hsb = Hsb::from_color(nc);
-                    changed = true;
-                    done |= d;
-                }
-                ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
-                // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
-                if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {
-                    gamut_warning(ui, &t);
-                }
-                if changed {
-                    match &mut paint.kind {
-                        PaintKind::Solid { color } => {
-                            *color = nc.with_alpha(1.0);
-                            paint.opacity = nc.a;
-                        }
-                        PaintKind::Gradient(g) => {
-                            let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);
-                            if let Some(s) = g.stops.get_mut(sel) {
-                                s.color = nc;
-                            }
+            let (c2, d2) = hue_slider(ui, hr, &mut hsb);
+            let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());
+            let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));
+            let mut new_alpha = None;
+            if let Some((a, d)) = alpha_slider(ui, ar, color) {
+                new_alpha = Some((a, d));
+            }
+            let mut nc = hsb.to_color().with_alpha(color.a);
+            let mut done = d1 || d2;
+            let mut changed = c1 || c2;
+            if let Some((a, d)) = new_alpha {
+                nc = nc.with_alpha(a);
+                changed = true;
+                done |= d;
+            }
+            // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.
+            if let Some((c, d)) = model_fields(ui, nc) {
+                nc = c;
+                hsb = Hsb::from_color(nc);
+                changed = true;
+                done |= d;
+            }
+            ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
+            // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
+            if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {
+                gamut_warning(ui, &t);
+            }
+            if changed {
+                match &mut paint.kind {
+                    PaintKind::Solid { color } => {
+                        *color = nc.with_alpha(1.0);
+                        paint.opacity = nc.a;
+                    }
+                    PaintKind::Gradient(g) => {
+                        let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);
+                        if let Some(s) = g.stops.get_mut(sel) {
+                            s.color = nc;
                         }
-                        PaintKind::Image(_) | PaintKind::Pattern(_) => {}
                     }
-                    write(app, &target, &paint, !done);
-                }
-                if done {
-                    finish(app);
+                    PaintKind::Image(_) | PaintKind::Pattern(_) => {}
                 }
+                write(app, &target, &paint, !done);
             }
-            // Colours used on this page.
-            let doc_colors = page_colors(app, ui);
-            if !doc_colors.is_empty() {
-                ui.horizontal(|ui| {
-                    ui.add_space(16.0);
-                    widgets::label(ui, tr("On this page"), t.text_secondary);
-                });
-                // Inset on both sides: `add_space` would only indent the first row of a wrap.
-                inset(ui, |ui| {
-                    for c in &doc_colors {
-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
-                        }
-                    }
-                });
+            if done {
+                finish(app);
             }
-            // Recent colours.
-            let recent = app.session.recent_colors.clone();
-            if !recent.is_empty() {
-                ui.horizontal(|ui| {
-                    ui.add_space(16.0);
-                    widgets::label(ui, tr("Recent"), t.text_secondary);
-                });
-                inset(ui, |ui| {
-                    for c in recent.iter().take(14) {
-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
-                        }
+        }
+        // Colours used on this page.
+        let doc_colors = page_colors(app, ui);
+        if !doc_colors.is_empty() {
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                widgets::label(ui, tr("On this page"), t.text_secondary);
+            });
+            // Inset on both sides: `add_space` would only indent the first row of a wrap.
+            inset(ui, |ui| {
+                for c in &doc_colors {
+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
                     }
-                });
-            }
-            ui.add_space(12.0);
-        });
-    if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
-        finish(app);
-        app.ui.picker = None;
+                }
+            });
+        }
+        // Recent colours.
+        let recent = app.session.recent_colors.clone();
+        if !recent.is_empty() {
+            ui.horizontal(|ui| {
+                ui.add_space(16.0);
+                widgets::label(ui, tr("Recent"), t.text_secondary);
+            });
+            inset(ui, |ui| {
+                for c in recent.iter().take(14) {
+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
+                    }
+                }
+            });
+        }
+        ui.add_space(12.0);
+    });
+    if closed {
+        close(app);
+    } else if app.ui.picker.is_some() {
+        // Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave
+        // a stale place for the next picker to inherit.
+        app.ui.picker_popover = Some(pop);
     }
-    let _ = Pos2::ZERO;
+}
+
+/// Escape with the picker open closes it (the selection stays); false when it was not open, so the
+/// key goes on to the binding that deselects.
+pub fn close_if_open(app: &mut FigApp) -> bool {
+    if app.ui.picker.is_none() {
+        return false;
+    }
+    close(app);
+    true
+}
+
+/// The colour picker closes (its X, a press outside it, Escape, or another popover took over):
+/// pending edits commit and the picker with its popover go.
+pub fn close(app: &mut FigApp) {
+    finish(app);
+    app.ui.picker = None;
+    app.ui.picker_popover = None;
+    app.ui.picker_button = None;
 }
 
 /// Swatches wrapped in rows inset 16 points from both sides of the picker.
@@ -1282,7 +1298,7 @@ mod selection_color_tests {
         s.execute("select.set", &json!({"ids":[a,b]})).unwrap();
         let mut app = FigApp::new(s, Box::new(NoServices));
         let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();
-        crate::selection_colors::open_picker(&mut app, targets);
+        crate::selection_colors::toggle(&mut app, targets, None);
         (app, a, b)
     }
 
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -19,11 +19,13 @@ use crate::a11y::Tip;
 use crate::colorpicker::PickerTarget;
 use crate::i18n::{tr, trf};
 use crate::icons::{self, Icon};
+use crate::popover::{Hold, Open, Popover, Surface};
 use crate::theme::{Tokens, regular, semibold};
 use crate::widgets;
 
-/// Content width of the popover.
-const W: f32 = 240.0;
+/// The slot's margin inside the shared popover, and the content width left of it.
+const PAD: f32 = 8.0;
+const W: f32 = 224.0;
 /// Height of a list row.
 const ROW_H: f32 = 26.0;
 /// Height of a text style's list row (its name in its own font).
@@ -167,6 +169,9 @@ impl Picker {
 /// Open the picker for `slot` of `ids`, level with the pointer.
 pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {
     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);
+    // Opening the style picker closes the colour picker (it would linger beside it).
+    crate::colorpicker::close(app);
+    app.ui.style_picker_popover = None;
     app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None });
 }
 
@@ -174,6 +179,8 @@ pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {
 pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) {
     let ids = app.session.selection();
     let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing));
+    crate::colorpicker::close(app);
+    app.ui.style_picker_popover = None;
     app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit });
 }
 
@@ -516,9 +523,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
     let link = link_of(app, doc, &p.ids, slot);
     let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id));
     let grid = slot.has_grid() && app.ui.style_grid;
-    // Header: title, then view toggle, detach and create.
+    // The title is the popover's; this row keeps the view toggle, detach and create.
     let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());
-    ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text);
     let mut x = hr.max.x - 24.0;
     let mut slot_rect = || {
         let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0));
@@ -919,7 +925,6 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
 /// Editing one style: name, description, its values (paints open the colour picker; text styles
 /// have their typography), update from the selection, delete.
 fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
-    let t = Tokens::get(ui.ctx());
     let slot = p.slot;
     let has_selection = !p.ids.is_empty();
     let Some(e) = p.edit.as_mut() else { return };
@@ -931,7 +936,6 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
     if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() {
         out.act = Some(Act::Back);
     }
-    ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text);
     ui.add_space(6.0);
     caption(ui, tr("Name"));
     let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));
@@ -1020,9 +1024,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         if let Some(e) = &p.edit {
             commit_edit(app, &doc, e);
         }
+        app.ui.style_picker_popover = None;
         return;
     }
-    let t = Tokens::get(ctx);
     let screen = ctx.content_rect();
     // Left of the Design panel; further left while the colour picker (which sits there) is open.
     let mut x = screen.max.x - app.ui.right_width - W - 32.0;
@@ -1031,34 +1035,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
     }
     let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));
     let mut out = Frame { act: None, hover: None, budget: 4 };
-    let area = egui::Area::new(Id::unique("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
-        egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
-            ui.set_width(W);
-            if p.edit.is_some() {
-                editor(ui, &doc, &mut p, &mut out);
-            } else {
-                listing(app, ui, &doc, &mut p, &mut out);
-            }
-        });
-    });
-    let rect = area.response.rect;
-    ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("style_picker_border"))).rect_stroke(
-        rect,
-        CornerRadius::same(10),
-        Stroke::new(1.0, Color32::from_black_alpha(40)),
-        egui::StrokeKind::Inside,
-    );
-    // Clicks in the colour picker editing this picker's style, or in a menu the editor opened,
-    // keep it open.
+    // The title and the X are the shared popover's now; the list keeps its tools row.
+    let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() };
+    let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu));
+    pop.title = title.to_string();
+    // Clicks in the colour picker editing this picker's style, in the variable picker binding one
+    // of its fields, or in a menu the editor opened, keep it open.
     let picker_rect =
         app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())));
-    // So do clicks in the variable picker binding one of its fields (Escape closes that first).
     let variables = crate::variable_ui::picker_rect(ctx);
-    let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some();
-    let outside = ctx.input(|i| {
-        i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos)))
-    }) && !in_popup;
-    let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup);
+    let holds: Vec<Rect> = picker_rect.into_iter().chain(variables).collect();
+    let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| {
+        if p.edit.is_some() {
+            editor(ui, &doc, &mut p, &mut out);
+        } else {
+            listing(app, ui, &doc, &mut p, &mut out);
+        }
+    });
+    let mut close = closed;
     let ids = p.ids.clone();
     let slot = p.slot;
     // The canvas shows the text style under the pointer on the selected text.
@@ -1131,8 +1125,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
         if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) {
             app.ui.picker = None;
         }
+        app.ui.style_picker_popover = None;
     } else {
         app.ui.style_picker = Some(p);
+        app.ui.style_picker_popover = Some(pop);
     }
 }
 
@@ -1140,6 +1136,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
 fn edit_local(app: &mut FigApp, ui: &Ui, slot: StyleSlot, id: &str) {
     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);
     let edit = app.session.doc().ok().and_then(|d| d.style(id).map(editing));
+    crate::colorpicker::close(app);
+    app.ui.style_picker_popover = None;
     app.ui.style_picker = Some(Picker { slot, ids: app.session.selection(), y, query: String::new(), edit });
 }
 
diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs
--- a/crates/ui-egui/src/widgets/style_picker/tests.rs
+++ b/crates/ui-egui/src/widgets/style_picker/tests.rs
@@ -323,17 +323,10 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
     u
 }
 
-/// The left edge of the style editor's content (its "Edit style" title starts 30 px in).
+/// The left edge of the style editor's content: the shared popover's slot, inside its margin.
 fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 {
-    let mut left = None;
-    for c in u.frame_output(vec![]).shapes {
-        if let egui::Shape::Text(t) = &c.shape
-            && t.galley.text() == "Edit style"
-        {
-            left = Some(t.pos.x - 30.0);
-        }
-    }
-    left.expect("the editor's title")
+    u.frame_output(vec![]);
+    u.app.ui.style_picker_popover.as_ref().expect("the editor's popover").slot.min.x
 }
 
 /// The texts drawn in one frame, with their centers.
