diff --git a/cspell.json b/cspell.json
index 4b0b15a4..163d7024 100644
--- a/cspell.json
+++ b/cspell.json
@@ -78,6 +78,7 @@
"Menlo",
"menuitemcheckbox",
"menuitemradio",
+ "middlewares",
"minlength",
"monospace",
"mousedown",
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 87c35676..74789235 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -77,6 +77,7 @@
- [Format Number](/components/format-number)
- [Include](/components/include)
- [Mutation Observer](/components/mutation-observer)
+ - [Popup](/components/Popup)
- [Relative Time](/components/relative-time)
- [Resize Observer](/components/resize-observer)
- [Responsive Media](/components/responsive-media)
diff --git a/docs/components/popup.md b/docs/components/popup.md
new file mode 100644
index 00000000..571b8d7e
--- /dev/null
+++ b/docs/components/popup.md
@@ -0,0 +1,112 @@
+# Popup
+
+[component-header:sl-popup]
+
+A description of the component goes here.
+
+```html preview
+
+
+
+
+
+
+
+```
+
+## Examples
+
+### Fixed Positioning Strategy
+
+By default, an absolute positioning strategy is used for maximum performance. However, if your content is within a container that has `overflow: auto|hidden` the popup will be clipped. To work around this, you can switch to the fixed positioning strategy by setting the `strategy` attribute to `fixed`.
+
+The fixed positioning strategy allows the content to break out containers that clip them. When using this strategy, it's important to note that the content will be positioned _relative to its containing block_, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
+
+TODO
+
+### Arrows
+
+TODO
+
+[component-metadata:sl-popup]
diff --git a/src/components/popup/popup.styles.ts b/src/components/popup/popup.styles.ts
new file mode 100644
index 00000000..de6cce46
--- /dev/null
+++ b/src/components/popup/popup.styles.ts
@@ -0,0 +1,38 @@
+import { css } from 'lit';
+import componentStyles from '../../styles/component.styles';
+
+export default css`
+ ${componentStyles}
+
+ :host {
+ --arrow-size: 4px;
+ --arrow-color: var(--sl-color-neutral-0);
+ --arrow-shadow: none;
+
+ display: inline;
+ }
+
+ .popup {
+ display: block;
+ position: absolute;
+ isolation: isolate;
+ }
+
+ .popup--fixed {
+ position: fixed;
+ }
+
+ .popup:not(.popup--active) {
+ display: none;
+ }
+
+ .popup__arrow {
+ position: absolute;
+ width: calc(var(--arrow-size) * 2);
+ height: calc(var(--arrow-size) * 2);
+ transform: rotate(45deg);
+ background: var(--arrow-color);
+ box-shadow: var(--arrow-shadow);
+ z-index: -1;
+ }
+`;
diff --git a/src/components/popup/popup.test.ts b/src/components/popup/popup.test.ts
new file mode 100644
index 00000000..ce0002d2
--- /dev/null
+++ b/src/components/popup/popup.test.ts
@@ -0,0 +1,9 @@
+import { expect, fixture, html } from '@open-wc/testing';
+
+describe('', () => {
+ it('should render a component', async () => {
+ const el = await fixture(html` `);
+
+ expect(el).to.exist;
+ });
+});
diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts
new file mode 100644
index 00000000..ed4bf411
--- /dev/null
+++ b/src/components/popup/popup.ts
@@ -0,0 +1,264 @@
+import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
+import { LitElement, html } from 'lit';
+import { customElement, property, query } from 'lit/decorators.js';
+import { classMap } from 'lit/directives/class-map.js';
+import { watch } from '../../internal/watch';
+import styles from './popup.styles';
+import type { CSSResultGroup } from 'lit';
+
+/**
+ * @since 2.0
+ * @status experimental
+ *
+ * @slot - The popup's content.
+ * @slot anchor - The element the popup will be anchored to.
+ *
+ * @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
+ * assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
+ * maybe a border or box shadow.
+ * @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
+ *
+ * @cssproperty [--arrow-size=4px] - The size of the arrow.
+ * @cssproperty [--arrow-color=4px] - The color of the arrow.
+ */
+@customElement('sl-popup')
+export default class SlPopup extends LitElement {
+ static styles: CSSResultGroup = styles;
+
+ @query('.popup') popupEl: HTMLElement;
+ @query('.popup__arrow') arrowEl: HTMLElement;
+
+ private anchor: HTMLElement | null;
+ private cleanup: ReturnType | undefined;
+
+ /** Activates popup logic and shows the popup. */
+ @property({ type: Boolean, reflect: true }) active = false;
+
+ /**
+ * The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
+ * panel inside of the viewport.
+ */
+ @property({ reflect: true }) placement:
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end' = 'bottom-start';
+
+ /**
+ * Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if
+ * overflow is clipped, using a `fixed` position strategy can often workaround it.
+ */
+ @property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
+
+ /** The distance in pixels from which to offset the panel away from its anchor. */
+ @property({ type: Number }) distance = 0;
+
+ /** The distance in pixels from which to offset the panel along its anchor. */
+ @property({ type: Number }) skidding = 0;
+
+ /** Moves the popup along the axis to keep it in view when clipped. */
+ @property({ type: Boolean }) shift = false;
+
+ /** Attaches an arrow to the popup. */
+ @property({ type: Boolean }) arrow = false;
+
+ /**
+ * When set, placement of the popup will flip to the opposite site to keep it in view. You can use
+ * `flipFallbackPlacement` to further configure how the fallback placement is determined.
+ */
+ @property({ type: Boolean }) flip = false;
+
+ /**
+ * If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
+ * string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
+ * fallback strategy will be used instead.
+ * */
+ @property() flipFallbackPlacement: string;
+
+ /**
+ * When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
+ * the popup should be positioned as it was initially preferred or using the best available fit based on available
+ * space.
+ */
+ @property() flipFallbackStrategy: 'bestFit' | 'initialPlacement' = 'initialPlacement';
+
+ async connectedCallback() {
+ super.connectedCallback();
+
+ // Start the positioner after the first update
+ await this.updateComplete;
+ this.start();
+ }
+
+ disconnectedCallback() {
+ this.stop();
+ }
+
+ async handleAnchorSlotChange() {
+ await this.stop();
+
+ this.anchor = this.querySelector('[slot="anchor"]');
+ if (!this.anchor) {
+ throw new Error('Invalid anchor element: no child with slot="anchor" was found.');
+ }
+
+ this.start();
+ }
+
+ private start() {
+ // We can't start the positioner without an anchor
+ if (!this.anchor) {
+ return;
+ }
+
+ this.cleanup = autoUpdate(this.anchor, this.popupEl, () => {
+ this.reposition();
+ });
+ }
+
+ private async stop(): Promise {
+ return new Promise(resolve => {
+ if (this.cleanup) {
+ this.cleanup();
+ this.cleanup = undefined;
+ requestAnimationFrame(() => resolve());
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ @watch('active', { waitUntilFirstUpdate: true })
+ handleActiveChange() {
+ if (this.active) {
+ this.reposition();
+ } else {
+ this.stop();
+ }
+ }
+
+ @watch('arrow')
+ @watch('distance')
+ @watch('flip')
+ @watch('flipFallbackPlacement')
+ @watch('flipFallbackStrategy')
+ @watch('placement')
+ @watch('shift')
+ @watch('skidding')
+ @watch('strategy')
+ async handlePositionChange() {
+ if (this.hasUpdated && this.active) {
+ await this.updateComplete;
+ this.reposition();
+ }
+ }
+
+ /** Recalculate and repositions the popup. */
+ reposition() {
+ if (!this.anchor) {
+ throw new Error('Invalid anchor element: no child with slot="anchor" was found.');
+ }
+
+ // Nothing to do if the popup is inactive
+ if (!this.active) {
+ return;
+ }
+
+ //
+ // NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
+ //
+ const middleware = [
+ // The offset middleware goes first
+ offset({ mainAxis: this.distance, crossAxis: this.skidding })
+ ];
+
+ // Then we flip, as needed
+ if (this.flip) {
+ middleware.push(
+ flip({
+ // @ts-expect-error - We're converting a string attribute to an array here
+ //
+ // TODO - use a custom adapter for this property
+ //
+ fallbackPlacement: this.flipFallbackPlacement.split(' ').filter(p => p.trim()),
+ fallbackStrategy: this.flipFallbackStrategy
+ })
+ );
+ }
+
+ // Then we shift, as needed
+ if (this.shift) {
+ middleware.push(shift());
+ }
+
+ // Finally, we add an arrow
+ if (this.arrow) {
+ middleware.push(
+ arrow({
+ element: this.arrowEl,
+ padding: 10 // min distance from the edge, in pixels
+ })
+ );
+ }
+
+ computePosition(this.anchor, this.popupEl, {
+ placement: this.placement,
+ middleware,
+ strategy: this.strategy
+ }).then(({ x, y, middlewareData, placement }) => {
+ const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
+
+ Object.assign(this.popupEl.style, {
+ left: `${x}px`,
+ top: `${y}px`
+ });
+
+ if (this.arrow) {
+ const arrowX = middlewareData.arrow?.x;
+ const arrowY = middlewareData.arrow?.y;
+
+ Object.assign(this.arrowEl.style, {
+ left: typeof arrowX === 'number' ? `${arrowX}px` : '',
+ top: typeof arrowY === 'number' ? `${arrowY}px` : '',
+ right: '',
+ bottom: '',
+ [staticSide]: 'calc(var(--arrow-size) * -1)'
+ });
+ }
+ });
+ }
+
+ render() {
+ return html`
+
+
+
+
+ ${this.arrow ? html`` : ''}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sl-popup': SlPopup;
+ }
+}
diff --git a/src/shoelace.ts b/src/shoelace.ts
index 8da0fd0c..b54c81d2 100644
--- a/src/shoelace.ts
+++ b/src/shoelace.ts
@@ -28,6 +28,7 @@ export { default as SlMenu } from './components/menu/menu';
export { default as SlMenuItem } from './components/menu-item/menu-item';
export { default as SlMenuLabel } from './components/menu-label/menu-label';
export { default as SlMutationObserver } from './components/mutation-observer/mutation-observer';
+export { default as SlPopup } from './components/popup/popup';
export { default as SlProgressBar } from './components/progress-bar/progress-bar';
export { default as SlProgressRing } from './components/progress-ring/progress-ring';
export { default as SlQrCode } from './components/qr-code/qr-code';