diff --git a/src/components/Popup/Popup.stories.tsx b/src/components/Popup/Popup.stories.tsx
new file mode 100644
index 00000000..5c5a9981
--- /dev/null
+++ b/src/components/Popup/Popup.stories.tsx
@@ -0,0 +1,100 @@
+import type { StoryObj, Meta } from "@storybook/web-components"
+import { html } from "lit"
+
+import "./Popup"
+
+const meta: Meta = {
+ title: "Components/Popup",
+ component: "nosto-popup",
+ argTypes: {
+ name: {
+ control: "text",
+ description: "Optional name used in analytics and localStorage for persistent closing"
+ },
+ segment: {
+ control: "text",
+ description: "Optional Nosto segment that acts as a precondition for activation"
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: `
+A popup component that displays content in a dialog with optional ribbon content.
+Supports segment-based activation and persistent closure state using localStorage.
+ `
+ }
+ }
+ }
+}
+
+export default meta
+type Story = StoryObj
+
+// reset popular storage key for demo purposes
+localStorage.clear()
+
+export const Default: Story = {
+ args: {},
+ render: () => html`
+
+
+
Special Offer!
+
Get 20% off your next purchase when you sign up for our newsletter.
+
+ Sign Up Now
+
+
+ Maybe Later
+
+
+ Close
+
+
+ Hello from the ribbon!
+
+ `
+}
+
+export const WithRibbon: Story = {
+ args: {},
+ render: () => html`
+
+
+
Limited Time Offer
+
Don't miss out on this exclusive deal!
+
+ Shop Now
+
+
+ Maybe Later
+
+
+
+ ⏰ Only 2 hours left!
+
+ ×
+
+
+
+ `
+}
diff --git a/src/components/Popup/Popup.ts b/src/components/Popup/Popup.ts
new file mode 100644
index 00000000..bfe4caa7
--- /dev/null
+++ b/src/components/Popup/Popup.ts
@@ -0,0 +1,151 @@
+import { nostojs } from "@nosto/nosto-js"
+import { customElement } from "../decorators"
+import { NostoElement } from "../Element"
+import { popupStyles } from "./styles"
+import { assertRequired } from "@/utils/assertRequired"
+
+/**
+ * A custom element that displays popup content with dialog and ribbon slots.
+ * Supports conditional activation based on Nosto segments and persistent closure state.
+ *
+ * @property {string} name - Required name used for analytics and localStorage persistence. The popup's closed state will be remembered.
+ * @property {string} [segment] - Optional Nosto segment that acts as a precondition for activation. Only users in this segment will see the popup.
+ *
+ * @example
+ * Basic popup with dialog and ribbon content:
+ * ```html
+ *
+ * Special Offer!
+ * Get 20% off your order today
+ * Close
+ *
+ * Limited time!
+ *
+ *
+ * ```
+ */
+@customElement("nosto-popup")
+export class Popup extends NostoElement {
+ /** @private */
+ static attributes = {
+ name: String,
+ segment: String
+ }
+
+ name!: string
+ segment?: string
+
+ constructor() {
+ super()
+ this.attachShadow({ mode: "open" })
+ }
+
+ async connectedCallback() {
+ assertRequired(this, "name")
+ const state = await getPopupState(this.name, this.segment)
+ if (state === "closed") {
+ this.style.display = "none"
+ return
+ }
+ if (!this.shadowRoot?.innerHTML) {
+ initializeShadowContent(this, state)
+ }
+ this.addEventListener("click", this.handleClick.bind(this))
+ setPopupState(this.name, "ribbon")
+ }
+
+ private handleClick(event: Event) {
+ const target = event.target as HTMLElement
+ const toClose = target?.matches("[n-close]") || target?.closest("[n-close]")
+ const toRibbon = target?.matches("[n-ribbon]") || target?.closest("[n-ribbon]")
+ const toOpen = target?.matches("[slot='ribbon']") || target?.closest("[slot='ribbon']")
+ console.log("Popup clicked:", target, { toOpen, toClose, toRibbon })
+
+ if (toOpen || toClose || toRibbon) {
+ event.preventDefault()
+ event.stopPropagation()
+ }
+ if (toClose) {
+ closePopup(this)
+ } else if (toOpen) {
+ updateShadowContent(this, "open")
+ } else if (toRibbon) {
+ setPopupState(this.name, "ribbon")
+ updateShadowContent(this, "ribbon")
+ }
+ }
+}
+
+const key = "nosto:web-components:popup"
+
+type PopupState = "open" | "ribbon" | "closed"
+
+type PopupData = {
+ name: string
+ state: PopupState
+}
+
+function initializeShadowContent(element: Popup, mode: "open" | "ribbon" = "open") {
+ element.shadowRoot!.innerHTML = `
+
+
+
+
+
+ Open
+
+ `
+ if (mode === "open") {
+ element.shadowRoot?.querySelector("dialog")?.showModal()
+ }
+}
+
+function updateShadowContent(element: Popup, mode: "open" | "ribbon" = "open") {
+ const dialog = element.shadowRoot?.querySelector("dialog")
+ const ribbon = element.shadowRoot?.querySelector(".ribbon")
+ if (dialog && ribbon) {
+ if (mode === "ribbon") {
+ dialog.close()
+ ribbon.classList.remove("hidden")
+ } else {
+ dialog.showModal()
+ ribbon.classList.add("hidden")
+ }
+ }
+}
+
+function closePopup(element: Popup) {
+ setPopupState(element.name, "closed")
+ element.style.display = "none"
+}
+
+async function getPopupState(name: string, segment?: string): Promise {
+ if (segment && !(await checkSegment(segment))) {
+ return "closed"
+ }
+ const dataStr = localStorage.getItem(key)
+ if (dataStr) {
+ const data = JSON.parse(dataStr) as PopupData
+ if (data.name !== name) {
+ return "closed"
+ }
+ return data.state
+ }
+ return "open"
+}
+
+function setPopupState(name: string, state: PopupState) {
+ localStorage.setItem(key, JSON.stringify({ name, state }))
+}
+
+async function checkSegment(segment: string) {
+ const api = await new Promise(nostojs)
+ const segments = await api.internal.getSegments()
+ return segments?.includes(segment) || false
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "nosto-popup": Popup
+ }
+}
diff --git a/src/components/Popup/styles.ts b/src/components/Popup/styles.ts
new file mode 100644
index 00000000..1e4c6ee3
--- /dev/null
+++ b/src/components/Popup/styles.ts
@@ -0,0 +1,40 @@
+export const popupStyles = `
+ :host {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ dialog {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ border: none;
+ border-radius: 8px;
+ padding: 0;
+ background: transparent;
+ pointer-events: auto;
+ }
+
+ dialog::backdrop {
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(2px);
+ }
+
+ .ribbon {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ pointer-events: auto;
+ z-index: 1002;
+ }
+
+ .hidden {
+ display: none;
+ }
+`
diff --git a/test/components/Popup/Popup.spec.tsx b/test/components/Popup/Popup.spec.tsx
new file mode 100644
index 00000000..5916bfda
--- /dev/null
+++ b/test/components/Popup/Popup.spec.tsx
@@ -0,0 +1,464 @@
+/** @jsx createElement */
+import { describe, beforeEach, afterEach, it, expect, vi } from "vitest"
+import { Popup } from "@/components/Popup/Popup"
+import { mockNostojs } from "@nosto/nosto-js/testing"
+import { createElement } from "../../utils/jsx"
+
+describe("Popup", () => {
+ const popupKey = "nosto:web-components:popup"
+
+ function getPopupData() {
+ return JSON.parse(localStorage.getItem(popupKey)!)
+ }
+
+ function setPopupData(data: { name: string; state: "open" | "ribbon" | "closed" }) {
+ localStorage.setItem(popupKey, JSON.stringify(data))
+ }
+
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear()
+
+ // Mock default successful segment check
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.resolve(["matching-segment"])
+ }
+ })
+ })
+
+ afterEach(() => {
+ // Clean up any popups created during tests
+ document.querySelectorAll("nosto-popup").forEach(el => el.remove())
+ })
+
+ describe("Basic functionality", () => {
+ it("should render shadow content with dialog and ribbon slots", async () => {
+ const popup = (
+
+ Dialog content
+ Ribbon content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.shadowRoot).toBeTruthy()
+ expect(popup.shadowRoot?.querySelector("dialog")).toBeTruthy()
+ expect(popup.shadowRoot?.querySelector(".ribbon")).toBeTruthy()
+ expect(popup.shadowRoot?.querySelector('slot[name="default"]')).toBeTruthy()
+ expect(popup.shadowRoot?.querySelector('slot[name="ribbon"]')).toBeTruthy()
+ })
+
+ it("should be visible by default", async () => {
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+ })
+ })
+
+ describe("Named popups and persistence", () => {
+ it("should hide popup if it was previously closed and name is set", async () => {
+ const popupName = "test-popup"
+ setPopupData({ name: popupName, state: "closed" })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).toBe("none")
+ })
+
+ it("should show popup if name is set but not previously closed", async () => {
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+ })
+
+ it("should throw error when name attribute is missing", async () => {
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ // Don't append to DOM to avoid automatic connectedCallback call
+ // that would cause unhandled error
+ await expect(popup.connectedCallback()).rejects.toThrow("Property name is required.")
+ })
+ })
+
+ describe("Segment-based activation", () => {
+ it("should show popup when user has matching segment", async () => {
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.resolve(["segment1", "target-segment", "segment3"])
+ }
+ })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+ })
+
+ it("should hide popup when user does not have matching segment", async () => {
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.resolve(["segment1", "segment2", "segment3"])
+ }
+ })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).toBe("none")
+ })
+
+ it("should show popup when no segment attribute is specified", async () => {
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+ })
+
+ it("should propagate segment API errors", async () => {
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.reject(new Error("API Error"))
+ }
+ })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ // Don't append to DOM to avoid automatic connectedCallback call
+ // that would cause unhandled error
+ await expect(popup.connectedCallback()).rejects.toThrow("API Error")
+ })
+ })
+
+ describe("Click handling and closing", () => {
+ it("should close popup when element with n-close attribute is clicked", async () => {
+ const popup = (
+
+
+ Close
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+
+ const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement
+ expect(closeButton).toBeTruthy()
+
+ closeButton.click()
+
+ expect(popup.style.display).toBe("none")
+ })
+
+ it("should store closed state in localStorage when popup has name", async () => {
+ const popupName = "persistent-popup"
+ const popup = (
+
+
+ Close
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement
+ closeButton.click()
+
+ expect(getPopupData()).toEqual({ name: popupName, state: "closed" })
+ })
+
+ it("should always store closed state in localStorage since name is required", async () => {
+ const popup = (
+
+
+ Close
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const closeButton = popup.querySelector("[n-close]") as HTMLButtonElement
+ closeButton.click()
+
+ expect(getPopupData()).toEqual({ name: "always-stores-popup", state: "closed" })
+ })
+
+ it("should handle click events on ribbon content with n-close", async () => {
+ const popup = (
+
+ Dialog content
+
+ Limited time!
+ ×
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const ribbonCloseButton = popup.querySelector('[slot="ribbon"] [n-close]') as HTMLButtonElement
+ expect(ribbonCloseButton).toBeTruthy()
+
+ ribbonCloseButton.click()
+
+ expect(popup.style.display).toBe("none")
+ expect(getPopupData()).toEqual({ name: "ribbon-popup", state: "closed" })
+ })
+
+ it("should not close popup when clicking elements without n-close attribute", async () => {
+ const popup = (
+
+
+
Some content
+
Regular button
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const regularButton = popup.querySelector("button:not([n-close])") as HTMLButtonElement
+ expect(regularButton).toBeTruthy()
+
+ regularButton.click()
+
+ expect(popup.style.display).not.toBe("none")
+ })
+
+ it("should prevent default and stop propagation on n-close click", async () => {
+ const popup = (
+
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const closeLink = popup.querySelector("[n-close]") as HTMLAnchorElement
+
+ const clickEvent = new Event("click", { bubbles: true, cancelable: true })
+ const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault")
+ const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation")
+
+ closeLink.dispatchEvent(clickEvent)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+ expect(stopPropagationSpy).toHaveBeenCalled()
+ expect(popup.style.display).toBe("none")
+ })
+
+ it("should close popup when clicking inside element with n-close attribute (ancestor support)", async () => {
+ const popup = (
+
+
+
+ Click anywhere inside this div
+ Inner button
+
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).not.toBe("none")
+
+ // Click on the inner button (child of element with n-close)
+ const innerButton = popup.querySelector("button") as HTMLButtonElement
+ expect(innerButton).toBeTruthy()
+
+ innerButton.click()
+
+ expect(popup.style.display).toBe("none")
+ })
+ })
+
+ describe("Combined scenarios", () => {
+ it("should respect both segment and closed state conditions", async () => {
+ const popupName = "segment-popup"
+
+ // First, close the popup
+ setPopupData({ name: popupName, state: "closed" })
+
+ // Set up segments that would normally show the popup
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.resolve(["matching-segment"])
+ }
+ })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ // Should be hidden because it was closed, despite matching segment
+ expect(popup.style.display).toBe("none")
+ })
+
+ it("should hide popup when segment doesn't match, even without closed state", async () => {
+ mockNostojs({
+ internal: {
+ getSegments: () => Promise.resolve(["other-segment"])
+ }
+ })
+
+ const popup = (
+
+ Content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ expect(popup.style.display).toBe("none")
+ })
+ })
+
+ describe("Ribbon mode functionality", () => {
+ it("should switch to ribbon mode when n-ribbon element is clicked", async () => {
+ const popup = (
+
+
+ Switch to Ribbon
+
+
+ Ribbon content
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const ribbonButton = popup.querySelector("[n-ribbon]") as HTMLButtonElement
+ expect(ribbonButton).toBeTruthy()
+
+ ribbonButton.click()
+
+ // Check that ribbon state is stored
+ expect(getPopupData()).toEqual({ name: "ribbon-test-popup", state: "ribbon" })
+
+ // Check DOM structure after switch
+ const dialog = popup.shadowRoot?.querySelector('[part="dialog"]')
+ const ribbon = popup.shadowRoot?.querySelector('[part="ribbon"]')
+ expect(dialog?.hasAttribute("open")).toBe(false)
+ expect(ribbon?.classList.contains("hidden")).toBe(false)
+ })
+
+ it("should render in ribbon mode when localStorage state is 'ribbon'", async () => {
+ const popupName = "persistent-ribbon-popup"
+ setPopupData({ name: popupName, state: "ribbon" })
+
+ const popup = (
+
+ Dialog content
+ Ribbon content
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const dialog = popup.shadowRoot?.querySelector('[part="dialog"]')
+ const ribbon = popup.shadowRoot?.querySelector('[part="ribbon"]')
+ expect(dialog?.hasAttribute("open")).toBe(false)
+ expect(ribbon?.classList.contains("hidden")).toBe(false)
+ })
+
+ it("should prevent default and stop propagation on n-ribbon click", async () => {
+ const popup = (
+
+
+
+ ) as Popup
+
+ document.body.appendChild(popup)
+ await popup.connectedCallback()
+
+ const ribbonLink = popup.querySelector("[n-ribbon]") as HTMLAnchorElement
+
+ const clickEvent = new Event("click", { bubbles: true, cancelable: true })
+ const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault")
+ const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation")
+
+ ribbonLink.dispatchEvent(clickEvent)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+ expect(stopPropagationSpy).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/test/setup.ts b/test/setup.ts
index 24d288dd..36e9e8ab 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -4,12 +4,20 @@ import "@/components/Campaign/Campaign"
import "@/components/Control/Control"
import "@/components/DynamicCard/DynamicCard"
import "@/components/Image/Image"
+import "@/components/Popup/Popup"
import "@/components/Product/Product"
import "@/components/ProductCard/ProductCard"
import "@/components/SectionCampaign/SectionCampaign"
import "@/components/SimpleCard/SimpleCard"
import "@/components/SkuOptions/SkuOptions"
+HTMLDialogElement.prototype.showModal = function () {
+ this.toggleAttribute("open", true)
+}
+HTMLDialogElement.prototype.close = function () {
+ this.toggleAttribute("open", false)
+}
+
beforeAll(() => {
// Components are automatically registered by their @customElement decorators
// when the modules are imported above.
diff --git a/test/utils/jsx.ts b/test/utils/jsx.ts
index 1e6d9513..b01d50ff 100644
--- a/test/utils/jsx.ts
+++ b/test/utils/jsx.ts
@@ -2,6 +2,7 @@ import type { Campaign } from "@/components/Campaign/Campaign"
import type { Control } from "@/components/Control/Control"
import type { DynamicCard } from "@/components/DynamicCard/DynamicCard"
import type { Image } from "@/components/Image/Image"
+import type { Popup } from "@/components/Popup/Popup"
import type { Product } from "@/components/Product/Product"
import type { ProductCard } from "@/components/ProductCard/ProductCard"
import type { SectionCampaign } from "@/components/SectionCampaign/SectionCampaign"
@@ -26,6 +27,7 @@ declare global {
"nosto-control": ElementMapping
"nosto-dynamic-card": ElementMapping
"nosto-image": ElementMapping
+ "nosto-popup": ElementMapping
"nosto-product": ElementMapping
"nosto-product-card": ElementMapping
"nosto-section-campaign": ElementMapping