Popover

Popovers are small overlays that open on demand. They let merchants access additional content and actions without cluttering the page.

Use when presenting a set of actions in a disclosable menu.

import {Button, Popover, ActionList} from '@shopify/polaris';
import {useState, useCallback} from 'react';

function PopoverWithActionListExample() {
  const [popoverActive, setPopoverActive] = useState(true);

  const togglePopoverActive = useCallback(
    () => setPopoverActive((popoverActive) => !popoverActive),
    [],
  );

  const activator = (
    <Button onClick={togglePopoverActive} disclosure>
      More actions
    </Button>
  );

  return (
    <div style={{height: '250px'}}>
      <Popover
        active={popoverActive}
        activator={activator}
        autofocusTarget="first-node"
        onClose={togglePopoverActive}
      >
        <ActionList
          actionRole="menuitem"
          items={[{content: 'Import'}, {content: 'Export'}]}
        />
      </Popover>
    </div>
  );
}

Use to present a combination of content, instructions, and actions in a panel for tasks that are of low or secondary importance to the current page. When used this way, popovers provide useful entry points to related features without overwhelming merchants.

import {Button, Popover, ActionList} from '@shopify/polaris';
import {useState, useCallback} from 'react';

function PopoverContentExample() {
  const [popoverActive, setPopoverActive] = useState(true);

  const togglePopoverActive = useCallback(
    () => setPopoverActive((popoverActive) => !popoverActive),
    [],
  );

  const activator = (
    <Button onClick={togglePopoverActive} disclosure>
      Sales channels
    </Button>
  );

  return (
    <div style={{height: '250px'}}>
      <Popover
        active={popoverActive}
        activator={activator}
        autofocusTarget="first-node"
        onClose={togglePopoverActive}
      >
        <Popover.Pane fixed>
          <Popover.Section>
            <p>Available sales channels</p>
          </Popover.Section>
        </Popover.Pane>
        <Popover.Pane>
          <ActionList
            actionRole="menuitem"
            items={[
              {content: 'Online store'},
              {content: 'Facebook'},
              {content: 'Shopify POS'},
            ]}
          />
        </Popover.Pane>
      </Popover>
    </div>
  );
}

Use to present secondary input tasks on demand.

import {Button, Popover, FormLayout, Select, TextField} from '@shopify/polaris';
import {useState, useCallback} from 'react';

function PopoverFormExample() {
  const [popoverActive, setPopoverActive] = useState(true);
  const [tagValue, setTagValue] = useState('');

  const togglePopoverActive = useCallback(
    () => setPopoverActive((popoverActive) => !popoverActive),
    [],
  );

  const handleTagValueChange = useCallback((value) => setTagValue(value), []);

  const activator = (
    <Button onClick={togglePopoverActive} disclosure>
      Filter
    </Button>
  );

  return (
    <div style={{height: '280px'}}>
      <Popover
        active={popoverActive}
        activator={activator}
        onClose={togglePopoverActive}
        ariaHaspopup={false}
        sectioned
      >
        <FormLayout>
          <Select label="Show all customers where:" options={['Tagged with']} />
          <TextField
            label="Tags"
            value={tagValue}
            onChange={handleTagValueChange}
            autoComplete="off"
          />
          <Button size="slim">Add filter</Button>
        </FormLayout>
      </Popover>
    </div>
  );
}

Use to present merchants with a list that dynamically loads more items on scroll or arrow down.

import {Button, Card, Popover, ResourceList, Avatar} from '@shopify/polaris';
import {useState, useCallback} from 'react';

function PopoverLazyLoadExample() {
  const [popoverActive, setPopoverActive] = useState(true);
  const [visibleStaffIndex, setVisibleStaffIndex] = useState(5);
  const staff = [
    'Abbey Mayert',
    'Abbi Senger',
    'Abdul Goodwin',
    'Abdullah Borer',
    'Abe Nader',
    'Abigayle Smith',
    'Abner Torphy',
    'Abraham Towne',
    'Abraham Vik',
    'Ada Fisher',
    'Adah Pouros',
    'Adam Waelchi',
    'Adan Zemlak',
    'Addie Wehner',
    'Addison Wexler',
    'Alex Hernandez',
  ];

  const togglePopoverActive = useCallback(
    () => setPopoverActive((popoverActive) => !popoverActive),
    [],
  );

  const handleScrolledToBottom = useCallback(() => {
    const totalIndexes = staff.length;
    const interval =
      visibleStaffIndex + 3 < totalIndexes
        ? 3
        : totalIndexes - visibleStaffIndex;

    if (interval > 0) {
      setVisibleStaffIndex(visibleStaffIndex + interval);
    }
  }, [staff.length, visibleStaffIndex]);

  const handleResourceListItemClick = useCallback(() => {}, []);

  const activator = (
    <Button onClick={togglePopoverActive} disclosure>
      View staff
    </Button>
  );

  const staffList = staff.slice(0, visibleStaffIndex).map((name) => ({
    name,
    initials: getInitials(name),
  }));

  return (
    <Card sectioned>
      <div style={{height: '280px'}}>
        <Popover
          sectioned
          active={popoverActive}
          activator={activator}
          onClose={togglePopoverActive}
          ariaHaspopup={false}
        >
          <Popover.Pane onScrolledToBottom={handleScrolledToBottom}>
            <ResourceList items={staffList} renderItem={renderItem} />
          </Popover.Pane>
        </Popover>
      </div>
    </Card>
  );

  function renderItem({name, initials}) {
    return (
      <ResourceList.Item
        id={name}
        media={<Avatar size="medium" name={name} initials={initials} />}
        onClick={handleResourceListItemClick}
      >
        {name}
      </ResourceList.Item>
    );
  }

  function getInitials(name) {
    return name
      .split(' ')
      .map((surnameOrFamilyName) => {
        return surnameOrFamilyName.slice(0, 1);
      })
      .join('');
  }
}

Use to help merchants browse, filter, and choose from a list of options.

import React, {useState} from 'react';
import {
  Listbox,
  TextField,
  Icon,
  Link,
  Heading,
  Popover,
  AutoSelection,
  Scrollable,
  EmptySearchResult,
  DisplayText,
} from '@shopify/polaris';
import {SearchMinor} from '@shopify/polaris-icons';


const actionValue = '__ACTION__';

const segments = [
  {
    label: 'All customers',
    id: 'gid://shopify/CustomerSegment/1',
    value: '0',
  },
  {
    label: 'VIP customers',
    id: 'gid://shopify/CustomerSegment/2',
    value: '1',
  },
  {
    label: 'New customers',
    id: 'gid://shopify/CustomerSegment/3',
    value: '2',
  },
  {
    label: 'Abandoned carts - last 30 days',
    id: 'gid://shopify/CustomerSegment/4',
    value: '3',
  },
  {
    label: 'Wholesale customers',
    id: 'gid://shopify/CustomerSegment/5',
    value: '4',
  },
  {
    label: 'Email subscribers',
    id: 'gid://shopify/CustomerSegment/6',
    value: '5',
  },
  {
    label: 'From New York',
    id: 'gid://shopify/CustomerSegment/7',
    value: '6',
  },
  {
    label: 'Repeat buyers',
    id: 'gid://shopify/CustomerSegment/8',
    value: '7',
  },
  {
    label: 'First time buyers',
    id: 'gid://shopify/CustomerSegment/9',
    value: '8',
  },
  {
    label: 'From Canada',
    id: 'gid://shopify/CustomerSegment/10',
    value: '9',
  },
  {
    label: 'Bought in last 60 days',
    id: 'gid://shopify/CustomerSegment/11',
    value: '10',
  },
  {
    label: 'Bought last BFCM',
    id: 'gid://shopify/CustomerSegment/12',
    value: '11',
  },
];

const lazyLoadSegments = Array.from(Array(100)).map((_, index) => ({
  label: `Other customers ${index + 13}`,
  id: `gid://shopify/CustomerSegment/${index + 13}`,
  value: `${index + 12}`,
}));

segments.push(...lazyLoadSegments);

const interval = 25;

function PopoverWithSearchableListboxExample() {
  const [pickerOpen, setPickerOpen] = useState(false);
  const [showFooterAction, setShowFooterAction] = useState(true);
  const [query, setQuery] = useState('');
  const [lazyLoading, setLazyLoading] = useState(false);
  const [willLoadMoreResults, setWillLoadMoreResults] = useState(true);
  const [visibleOptionIndex, setVisibleOptionIndex] = useState(6);
  const [activeOptionId, setActiveOptionId] = useState(segments[0].id);
  const [selectedSegmentIndex, setSelectedSegmentIndex] = useState(0);
  const [filteredSegments, setFilteredSegments] = useState([]);

  const handleClickShowAll = () => {
    setShowFooterAction(false);
    setVisibleOptionIndex(interval);
  };

  const handleFilterSegments = (query: string) => {
    const nextFilteredSegments = segments.filter((segment) => {
      return segment.label
        .toLocaleLowerCase()
        .includes(query.toLocaleLowerCase().trim());
    });

    setFilteredSegments(nextFilteredSegments);
  };

  const handleQueryChange = (query: string) => {
    setQuery(query);

    if (query.length >= 2) handleFilterSegments(query);
  };

  const handleQueryClear = () => {
    handleQueryChange('');
  };

  const handleOpenPicker = () => {
    setPickerOpen(true);
  };

  const handleClosePicker = () => {
    setPickerOpen(false);
    handleQueryChange('');
  };

  const handleSegmentSelect = (segmentIndex: string) => {
    if (segmentIndex === actionValue) {
      return handleClickShowAll();
    }

    setSelectedSegmentIndex(Number(segmentIndex));
    handleClosePicker();
  };

  const handleActiveOptionChange = (_: string, domId: string) => {
    setActiveOptionId(domId);
  };

  /* This is just to illustrate lazy loading state vs loading state. This is an example, so we aren't fetching from GraphQL. You'd use `pageInfo.hasNextPage` from your GraphQL query data instead of this fake "willLoadMoreResults" state along with setting `first` your GraphQL query's variables to your app's default max edges limit (e.g., 250). */

  const handleLazyLoadSegments = () => {
    if (willLoadMoreResults && !showFooterAction) {
      setLazyLoading(true);

      const options = query ? filteredSegments : segments;

      setTimeout(() => {
        const remainingOptionCount = options.length - visibleOptionIndex;
        const nextVisibleOptionIndex =
          remainingOptionCount >= interval
            ? visibleOptionIndex + interval
            : visibleOptionIndex + remainingOptionCount;

        setLazyLoading(false);
        setVisibleOptionIndex(nextVisibleOptionIndex);

        if (remainingOptionCount <= interval) {
          setWillLoadMoreResults(false);
        }
      }, 1000);
    }
  };

  const listboxId = 'SearchableListboxInPopover';

  /* Your app's feature/context specific activator here */
  const activator = (
    <div
      style={{
        fontSize: 'var(--p-font-size-300)',
        color: 'var(--p-text)',
        borderBottom: '1px dashed var(--p-border)',
      }}
    >
      <Link monochrome removeUnderline onClick={handleOpenPicker}>
        <DisplayText element="h1">
          {segments[selectedSegmentIndex].label}
        </DisplayText>
      </Link>
    </div>
  );

  const textFieldMarkup = (
    <div style={{padding: '12px'}}>
      <StopPropagation>
        <TextField
          focused={showFooterAction}
          clearButton
          labelHidden
          label="Customer segments"
          placeholder="Search segments"
          autoComplete="off"
          value={query}
          prefix={<Icon source={SearchMinor} />}
          ariaActiveDescendant={activeOptionId}
          ariaControls={listboxId}
          onChange={handleQueryChange}
          onClearButtonClick={handleQueryClear}
        />
      </StopPropagation>
    </div>
  );

  const segmentOptions = query ? filteredSegments : segments;

  const segmentList =
    segmentOptions.length > 0
      ? segmentOptions
          .slice(0, visibleOptionIndex)
          .map(({label, id, value}) => {
            const selected = segments[selectedSegmentIndex].id === id;

            return (
              <Listbox.Option key={id} value={value} selected={selected}>
                <Listbox.TextOption selected={selected}>
                  {label}
                </Listbox.TextOption>
              </Listbox.Option>
            );
          })
      : null;

  const showAllMarkup = showFooterAction ? (
    <Listbox.Action value={actionValue}>
      <span style={{color: 'var(--p-interactive)'}}>Show all 111 segments</span>
    </Listbox.Action>
  ) : null;

  const lazyLoadingMarkup = lazyLoading ? (
    <Listbox.Loading
      accessibilityLabel={`${
        query ? 'Filtering' : 'Loading'
      } customer segments`}
    />
  ) : null;

  const noResultsMarkup =
    segmentOptions.length === 0 ? (
      <EmptySearchResult
        title=""
        description={`No segments found matching "${query}"`}
      />
    ) : null;

  const listboxMarkup = (
    <Listbox
      enableKeyboardControl
      autoSelection={AutoSelection.FirstSelected}
      accessibilityLabel="Search for and select a customer segment"
      customListId={listboxId}
      onSelect={handleSegmentSelect}
      onActiveOptionChange={handleActiveOptionChange}
    >
      {segmentList}
      {showAllMarkup}
      {noResultsMarkup}
      {lazyLoadingMarkup}
    </Listbox>
  );

  return (
    <div style={{height: '400px'}}>
      <Popover
        active={pickerOpen}
        activator={activator}
        ariaHaspopup="listbox"
        preferredAlignment="left"
        autofocusTarget="first-node"
        onClose={handleClosePicker}
      >
        <Popover.Pane fixed>
          <div
            style={{
              alignItems: 'stretch',
              borderTop: '1px solid #DFE3E8',
              display: 'flex',
              flexDirection: 'column',
              justifyContent: 'stretch',
              position: 'relative',
              width: '100%',
              height: '100%',
              overflow: 'hidden',
            }}
          >
            {textFieldMarkup}

            <Scrollable
              shadow
              style={{
                position: 'relative',
                width: '310px',
                height: '292px',
                padding: 'var(--p-space-2) 0',
                borderBottomLeftRadius: 'var(--p-border-radius-2)',
                borderBottomRightRadius: 'var(--p-border-radius-2)',
              }}
              onScrolledToBottom={handleLazyLoadSegments}
            >
              {listboxMarkup}
            </Scrollable>
          </div>
        </Popover.Pane>
      </Popover>
    </div>
  );
}

const StopPropagation = ({children}: React.PropsWithChildren<any>) => {
  const stopEventPropagation = (event: React.MouseEvent | React.TouchEvent) => {
    event.stopPropagation();
  };

  return (
    <div onClick={stopEventPropagation} onTouchStart={stopEventPropagation}>
      {children}
    </div>
  );
};

Props

Want to help make this feature better? Please share your feedback.

interface PopoverProps
children?React.ReactNode

The content to display inside the popover.

preferredPosition?'above' | 'below' | 'mostSpace'

The preferred direction to open the popover.

preferredAlignment?'left' | 'center' | 'right'

The preferred alignment of the popover relative to its activator.

activeboolean

Show or hide the Popover.

activatorReact.ReactElement

The element to activate the Popover.

preferInputActivator?boolean

Use the activator's input element to calculate the Popover position.

Defaults to true.

activatorWrapper?string

The element type to wrap the activator with.

Defaults to 'div'.

zIndexOverride?number

Override on the default z-index of 400.

preventFocusOnClose?boolean

Prevents focusing the activator or the next focusable element when the popover is deactivated.

sectioned?boolean

Automatically add wrap content in a section.

fullWidth?boolean

Allow popover to stretch to the full width of its activator.

fullHeight?boolean

Allow popover to stretch to fit content vertically.

fluidContent?boolean

Allow popover content to determine the overlay width and height.

fixed?boolean

Remains in a fixed position.

ariaHaspopup?boolean | "false" | "true" | "dialog" | "grid" | "listbox" | "menu" | "tree"

Used to illustrate the type of popover element.

hideOnPrint?boolean

Allow the popover overlay to be hidden when printing.

onClose(source: ) => void

Callback when popover is closed.

autofocusTarget?'none' | 'first-node' | 'container'

The preferred auto focus target defaulting to the popover container.

Defaults to 'container'.

preventCloseOnChildOverlayClick?boolean

Prevents closing the popover when other overlays are clicked.

Best practices

Popovers should:

  • Always be positioned next to the button or other interface element that triggers them
  • Be used for secondary or less important information and actions since they’re hidden until merchants hit the trigger
  • Contain navigation or actions that share a relationships to each other
  • Be triggered by a clearly labeled button

Content guidelines

Popover content

If a popover contains actions, they should:

  • Be clear and predictable: merchants should be able to anticipate what will happen when they click on an action item. Never deceive merchants by mislabeling an action.

Do

  • Create order
  • Buy shipping label

Don’t

  • New order
  • Buy
  • Be action-led: buttons should always lead with a strong verb that encourages action. To provide enough context to merchants use the {verb}+{noun} format on buttons except in the case of common actions like Save, Close, Cancel, or OK.

Do

  • Rename
  • Edit HTML
  • Duplicate

Don’t

  • HTML editing options
  • File name changes
  • Duplicate this order so that you can make edits, updates, or changes
  • Be scannable, especially when the popover contains a list of actions or options. Avoid unnecessary words and articles such as “the”, “an”, or “a”.

Do

  • Add menu item

Don’t

  • Add a menu item

If the popover includes a series of navigational links, each item should:

  • Be concise but still give merchants enough information so they can easily find and accurately navigate to the path they want.

Do

  • Online store
  • Messenger
  • Facebook
  • Buy Button

Don’t

  • Sales channel


Accessibility

Popovers usually contain an option list or an action list, but can also contain other controls or content.

To assist screen readers with sending focus to an action list, pass autofocusTarget='first-node' to Popover. This will avoid known issues a screen reader may have with keyboard support once focus is moved off the activator.

Web browsers assign a default value of 'menu' to the aria-haspopup role. You can use the prop ariaHaspopup to specify a value. Screen readers may fail to send focus to the Popover content when they expect the content to be adjacent to the element with aria-haspopup in the DOM tree. In this scenario, it is recommended not to provide the ariaHaspopup prop.

Keyboard support

  • When a popover opens, focus moves to the first focusable element or to the popover container
  • Once focus is in the popover, merchants can access controls in the popover using the tab key (and shift + tab backwards) and standard keystrokes for interacting
  • Merchants can dismiss the popover by tabbing out of it, pressing the esc key, or clicking outside of it
  • When the popover is closed, focus returns to the element that launched it