diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccb355c..754c722 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Added
+
+- `FloatingToggle`: single-cell floating card with an `aria-pressed` icon button, the on/off counterpart of the `floating` `SegmentedControl`, for placing beside `ZoomControls`.
+
+### Changed
+
+- `floatingControlCellStateClassName` in `floating-control.ts` now holds the selected/unselected cell styles shared by `SegmentedControl` and `FloatingToggle`.
+
## [0.23.0] - 2026-11-12
### Added
diff --git a/packages/ui/src/FloatingToggle.test.tsx b/packages/ui/src/FloatingToggle.test.tsx
new file mode 100644
index 0000000..421ec83
--- /dev/null
+++ b/packages/ui/src/FloatingToggle.test.tsx
@@ -0,0 +1,36 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { FloatingToggle } from "./FloatingToggle";
+
+describe("FloatingToggle", () => {
+ it("exposes the pressed state", () => {
+ render(
+ {}}>
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Night mode" })).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("requests the opposite state on click", () => {
+ const onPressedChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Night mode" }));
+ expect(onPressedChange).toHaveBeenCalledWith(true);
+ });
+
+ it("does not fire when disabled", () => {
+ const onPressedChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Night mode" }));
+ expect(onPressedChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/FloatingToggle.tsx b/packages/ui/src/FloatingToggle.tsx
new file mode 100644
index 0000000..b294f6b
--- /dev/null
+++ b/packages/ui/src/FloatingToggle.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { ReactNode } from "react";
+import { cn } from "./class-name";
+import {
+ floatingControlCardClassName,
+ floatingControlCellClassName,
+ floatingControlCellStateClassName,
+} from "./floating-control";
+
+export interface FloatingToggleProps {
+ pressed: boolean;
+ onPressedChange: (pressed: boolean) => void;
+ /** Icon shown in the cell. */
+ children: ReactNode;
+ "aria-label": string;
+ disabled?: boolean;
+ title?: string;
+ className?: string;
+}
+
+/**
+ * Single-cell floating card holding an on/off icon button: the toggle counterpart of the
+ * `floating` `SegmentedControl`. Unpositioned: the caller places it over its canvas.
+ */
+export function FloatingToggle({
+ pressed,
+ onPressedChange,
+ children,
+ "aria-label": ariaLabel,
+ disabled,
+ title,
+ className,
+}: FloatingToggleProps) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/ui/src/SegmentedControl.tsx b/packages/ui/src/SegmentedControl.tsx
index 8f92cee..4780819 100644
--- a/packages/ui/src/SegmentedControl.tsx
+++ b/packages/ui/src/SegmentedControl.tsx
@@ -2,7 +2,11 @@
import { ReactNode } from "react";
import { cn } from "./class-name";
-import { floatingControlCardClassName, floatingControlCellClassName } from "./floating-control";
+import {
+ floatingControlCardClassName,
+ floatingControlCellClassName,
+ floatingControlCellStateClassName,
+} from "./floating-control";
export interface SegmentedControlOption {
value: T;
@@ -62,17 +66,11 @@ export function SegmentedControl({
floating
? cn("inline-flex items-center justify-center rounded-md", floatingControlCellClassName)
: "rounded px-3 py-1 text-sm [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:px-4",
- selected
- ? cn(
- "text-[var(--color-text)]",
- floating
- ? "bg-[var(--color-surface-muted)] ring-1 ring-inset ring-[var(--color-border)]"
- : "bg-[var(--color-surface)] font-medium shadow-sm",
- )
- : cn(
- "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)]",
- floating && "enabled:hover:bg-[var(--color-surface-muted)]",
- ),
+ floating
+ ? floatingControlCellStateClassName(selected)
+ : selected
+ ? "bg-[var(--color-surface)] font-medium text-[var(--color-text)] shadow-sm"
+ : "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)]",
)}
>
{option.label}
diff --git a/packages/ui/src/floating-control.ts b/packages/ui/src/floating-control.ts
index aba0c5b..32df2a8 100644
--- a/packages/ui/src/floating-control.ts
+++ b/packages/ui/src/floating-control.ts
@@ -7,3 +7,10 @@ export const floatingControlCardClassName =
export const floatingControlCellClassName =
"h-8 w-8 [@media(pointer:coarse)]:h-[var(--layout-touch-target)] [@media(pointer:coarse)]:w-[var(--layout-touch-target)]";
+
+/** Selected / unselected look of a floating cell, shared by every pressed-state control. */
+export function floatingControlCellStateClassName(selected: boolean): string {
+ return selected
+ ? "text-[var(--color-text)] bg-[var(--color-surface-muted)] ring-1 ring-inset ring-[var(--color-border)]"
+ : "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)] enabled:hover:bg-[var(--color-surface-muted)]";
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 423d0fe..0196fbf 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -6,6 +6,7 @@ export * from "./Button";
export * from "./IconTextButton";
export * from "./IconButton";
export * from "./Avatar";
+export * from "./FloatingToggle";
export * from "./SegmentedControl";
export * from "./PanelSection";
export * from "./ZoomControls";