,
+ forwardedRef: ForwardedRef< any >
+): JSX.Element | null {
+ const {
+ as: asProp = 'span',
+ shortcut,
+ className,
+ ...otherProps
+ } = useContextSystem( props, 'Shortcut' );
+ if ( ! shortcut ) {
+ return null;
+ }
+
+ let displayText: string;
+ let ariaLabel: string | undefined;
+
+ if ( typeof shortcut === 'string' ) {
+ displayText = shortcut;
+ } else {
+ displayText = shortcut.display;
+ ariaLabel = shortcut.ariaLabel;
+ }
+
+ return (
+
+ { displayText }
+
+ );
+}
+
+const ConnectedShortcut = contextConnect( Shortcut, 'Shortcut' );
+
+export default ConnectedShortcut;
diff --git a/packages/components/src/ui/shortcut/index.ts b/packages/components/src/ui/shortcut/index.ts
new file mode 100644
index 0000000000000..6b107e956edb2
--- /dev/null
+++ b/packages/components/src/ui/shortcut/index.ts
@@ -0,0 +1,2 @@
+export { default as Shortcut } from './component';
+export type { Props as ShortcutProps, ShortcutDescription } from './component';
diff --git a/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap b/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap
new file mode 100644
index 0000000000000..edd4ed91bc352
--- /dev/null
+++ b/packages/components/src/ui/shortcut/test/__snapshots__/index.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Shortcut should render a span with the shortcut text 1`] = `
+
+ meta + P
+
+`;
+
+exports[`Shortcut should render null when no shortcut is provided 1`] = ``;
diff --git a/packages/components/src/ui/shortcut/test/index.js b/packages/components/src/ui/shortcut/test/index.js
new file mode 100644
index 0000000000000..7dc49f7b3f663
--- /dev/null
+++ b/packages/components/src/ui/shortcut/test/index.js
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { Shortcut } from '..';
+
+describe( 'Shortcut', () => {
+ it( 'should render null when no shortcut is provided', () => {
+ const { container } = render( );
+ expect( container ).toMatchSnapshot();
+ } );
+
+ it( 'should render a span with the shortcut text', () => {
+ const shortcutText = 'meta + P';
+ render( );
+ const shortcut = screen.getByText( shortcutText );
+ expect( shortcut ).toMatchSnapshot();
+ } );
+
+ it( 'should render a span with aria label', () => {
+ const shortcutObject = {
+ display: 'meta + P',
+ ariaLabel: 'print',
+ };
+ render( );
+ const shortcut = screen.getByText( shortcutObject.display );
+ expect( shortcut ).toHaveAttribute(
+ 'aria-label',
+ shortcutObject.ariaLabel
+ );
+ } );
+} );
diff --git a/packages/components/src/ui/tooltip/README.md b/packages/components/src/ui/tooltip/README.md
new file mode 100644
index 0000000000000..424da79828a48
--- /dev/null
+++ b/packages/components/src/ui/tooltip/README.md
@@ -0,0 +1,21 @@
+# Tooltip
+
+
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
+
+
+`Tooltip` is a component to render floating help text relative to a node when it receives focus or when the user places the mouse cursor atop it.
+
+## Usage
+
+```jsx
+import { Tooltip, Text } from '@wordpress/components/ui';
+
+function Example() {
+ return (
+
+ WordPress.org
+
+ );
+}
+```
diff --git a/packages/components/src/ui/tooltip/component.js b/packages/components/src/ui/tooltip/component.js
new file mode 100644
index 0000000000000..33106379140ff
--- /dev/null
+++ b/packages/components/src/ui/tooltip/component.js
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import { TooltipReference, useTooltipState } from 'reakit';
+
+/**
+ * WordPress dependencies
+ */
+import { useMemo, cloneElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { contextConnect, useContextSystem } from '../context';
+import { TooltipContext } from './context';
+import TooltipContent from './content';
+import { TooltipShortcut } from './styles';
+
+/**
+ * @param {import('../context').WordPressComponentProps} props
+ * @param {import('react').ForwardedRef} forwardedRef
+ */
+function Tooltip( props, forwardedRef ) {
+ const {
+ animated = true,
+ animationDuration = 160,
+ baseId,
+ children,
+ content,
+ focusable = true,
+ gutter = 4,
+ id,
+ modal = true,
+ placement,
+ visible = false,
+ shortcut,
+ ...otherProps
+ } = useContextSystem( props, 'Tooltip' );
+
+ const tooltip = useTooltipState( {
+ animated: animated ? animationDuration : undefined,
+ baseId: baseId || id,
+ gutter,
+ placement,
+ visible,
+ ...otherProps,
+ } );
+
+ const contextProps = useMemo(
+ () => ( {
+ tooltip,
+ } ),
+ [ tooltip ]
+ );
+
+ return (
+
+ { content && (
+
+ { content }
+ { shortcut && }
+
+ ) }
+ { children && (
+
+ { ( referenceProps ) => {
+ if ( ! focusable ) {
+ referenceProps.tabIndex = undefined;
+ }
+ return cloneElement( children, referenceProps );
+ } }
+
+ ) }
+
+ );
+}
+
+/**
+ * `Tooltip` is a component that provides context for a user interface element.
+ *
+ * @example
+ * ```jsx
+ * import { Tooltip, Text } from `@wordpress/components/ui`;
+ *
+ * function Example() {
+ * return (
+ *
+ * WordPress.org
+ *
+ * )
+ * }
+ * ```
+ */
+const ConnectedTooltip = contextConnect( Tooltip, 'Tooltip' );
+
+export default ConnectedTooltip;
diff --git a/packages/components/src/ui/tooltip/content.js b/packages/components/src/ui/tooltip/content.js
new file mode 100644
index 0000000000000..7bbf21b44aea8
--- /dev/null
+++ b/packages/components/src/ui/tooltip/content.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import { Tooltip as ReakitTooltip } from 'reakit';
+
+/**
+ * Internal dependencies
+ */
+import { contextConnect, useContextSystem } from '../context';
+import { View } from '../../view';
+import { useTooltipContext } from './context';
+import * as styles from './styles';
+import { useCx } from '../../utils/hooks/use-cx';
+
+const { TooltipPopoverView } = styles;
+
+/**
+ *
+ * @param {import('../context').WordPressComponentProps} props
+ * @param {import('react').ForwardedRef} forwardedRef
+ */
+function TooltipContent( props, forwardedRef ) {
+ const { children, className, ...otherProps } = useContextSystem(
+ props,
+ 'TooltipContent'
+ );
+ const { tooltip } = useTooltipContext();
+ const cx = useCx();
+ const classes = cx( styles.TooltipContent, className );
+
+ return (
+
+ { children }
+
+ );
+}
+
+export default contextConnect( TooltipContent, 'TooltipContent' );
diff --git a/packages/components/src/ui/tooltip/context.js b/packages/components/src/ui/tooltip/context.js
new file mode 100644
index 0000000000000..de7f81f9cdeb0
--- /dev/null
+++ b/packages/components/src/ui/tooltip/context.js
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext, useContext } from '@wordpress/element';
+
+/**
+ * @type {import('react').Context<{ tooltip?: import('reakit').TooltipState }>}
+ */
+export const TooltipContext = createContext( {} );
+export const useTooltipContext = () => useContext( TooltipContext );
diff --git a/packages/components/src/ui/tooltip/index.js b/packages/components/src/ui/tooltip/index.js
new file mode 100644
index 0000000000000..96841fe7ff4ed
--- /dev/null
+++ b/packages/components/src/ui/tooltip/index.js
@@ -0,0 +1,2 @@
+export { default as Tooltip } from './component';
+export * from './context';
diff --git a/packages/components/src/ui/tooltip/stories/index.story.js b/packages/components/src/ui/tooltip/stories/index.story.js
new file mode 100644
index 0000000000000..a0032da0d0ab9
--- /dev/null
+++ b/packages/components/src/ui/tooltip/stories/index.story.js
@@ -0,0 +1,26 @@
+/**
+ * Internal dependencies
+ */
+import { Text } from '../../../text';
+import { Tooltip } from '../index';
+
+export default {
+ component: Tooltip,
+ title: 'Components (Experimental)/Tooltip',
+};
+
+export const _default = () => {
+ return (
+
+ Hello
+
+ );
+};
diff --git a/packages/components/src/ui/tooltip/styles.js b/packages/components/src/ui/tooltip/styles.js
new file mode 100644
index 0000000000000..fca4875a9d1c1
--- /dev/null
+++ b/packages/components/src/ui/tooltip/styles.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+/**
+ * Internal dependencies
+ */
+import { Shortcut } from '../shortcut';
+import * as ZIndex from '../../utils/z-index';
+import CONFIG from '../../utils/config-values';
+import { space } from '../utils/space';
+import { COLORS } from '../../utils/colors-values';
+
+export const TooltipContent = css`
+ z-index: ${ ZIndex.Tooltip };
+ box-sizing: border-box;
+ opacity: 0;
+ outline: none;
+ transform-origin: top center;
+ transition: opacity ${ CONFIG.transitionDurationFastest } ease;
+ font-size: ${ CONFIG.fontSize };
+
+ &[data-enter] {
+ opacity: 1;
+ }
+`;
+
+export const TooltipPopoverView = styled.div`
+ background: rgba( 0, 0, 0, 0.8 );
+ border-radius: 2px;
+ box-shadow: 0 0 0 1px rgba( 255, 255, 255, 0.04 );
+ color: ${ COLORS.white };
+ padding: 4px 8px;
+`;
+
+export const noOutline = css`
+ outline: none;
+`;
+
+export const TooltipShortcut = styled( Shortcut )`
+ display: inline-block;
+ margin-left: ${ space( 1 ) };
+`;
diff --git a/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap b/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap
new file mode 100644
index 0000000000000..60edeba1681a2
--- /dev/null
+++ b/packages/components/src/ui/tooltip/test/__snapshots__/index.js.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`props should render correctly 1`] = `
+.emotion-0 {
+ z-index: 1000002;
+ box-sizing: border-box;
+ opacity: 0;
+ outline: none;
+ transform-origin: top center;
+ -webkit-transition: opacity 100ms ease;
+ transition: opacity 100ms ease;
+ font-size: 13px;
+}
+
+.emotion-0[data-enter] {
+ opacity: 1;
+}
+
+.emotion-2 {
+ background: rgba( 0, 0, 0, 0.8 );
+ border-radius: 2px;
+ box-shadow: 0 0 0 1px rgba( 255, 255, 255, 0.04 );
+ color: #fff;
+ padding: 4px 8px;
+}
+
+
+`;
diff --git a/packages/components/src/ui/tooltip/test/index.js b/packages/components/src/ui/tooltip/test/index.js
new file mode 100644
index 0000000000000..b95a866738bd2
--- /dev/null
+++ b/packages/components/src/ui/tooltip/test/index.js
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { Text } from '../../../text';
+import { Tooltip } from '../index';
+
+describe( 'props', () => {
+ const baseTooltipId = 'base-tooltip';
+ const baseTooltipTriggerContent = 'WordPress.org - Base trigger content';
+ const byId = ( id ) => ( t ) => t.id === id;
+ const VisibleTooltip = () => (
+
+ { baseTooltipTriggerContent }
+
+ );
+
+ test( 'should render correctly', () => {
+ render( );
+ const tooltip = screen.getByRole( 'tooltip' );
+ expect( tooltip ).toMatchSnapshot();
+ } );
+
+ test( 'should render invisible', () => {
+ render( );
+ const invisibleTooltipTriggerContent = 'WordPress.org - Invisible';
+ render(
+
+ { invisibleTooltipTriggerContent }
+
+ );
+ const tooltip = screen.getByRole( 'tooltip' );
+ const invisibleTooltipTrigger = screen.getByText(
+ invisibleTooltipTriggerContent
+ );
+ // The base tooltip should render only; invisible tooltip should not render.
+ expect( tooltip ).toBeInTheDocument();
+ // Assert that the rendered tooltip is indeed the base tooltip.
+ expect( tooltip.id ).toBe( baseTooltipId );
+ // But the invisible tooltip's trigger still should have rendered.
+ expect( invisibleTooltipTrigger ).not.toBeUndefined();
+ } );
+
+ test( 'should render without children', () => {
+ render( );
+ const childlessTooltipId = 'tooltip-without-children';
+ render(
+
+ );
+ const tooltips = screen.getAllByRole( 'tooltip' );
+ const childlessTooltip = tooltips.find( byId( childlessTooltipId ) );
+ expect( childlessTooltip ).not.toBeUndefined();
+ } );
+
+ test( 'should not render a tooltip without content', () => {
+ render( );
+ const contentlessTooltipId = 'contentless-tooltip';
+ render(
+
+ WordPress.org
+
+ );
+ const tooltip = screen.getByRole( 'tooltip' );
+ // Assert only the base tooltip rendered.
+ expect( tooltip ).toBeInTheDocument();
+ expect( tooltip.id ).toBe( baseTooltipId );
+ } );
+} );
diff --git a/packages/components/src/ui/tooltip/types.ts b/packages/components/src/ui/tooltip/types.ts
new file mode 100644
index 0000000000000..7c50dca3a2937
--- /dev/null
+++ b/packages/components/src/ui/tooltip/types.ts
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+// eslint-disable-next-line no-restricted-imports
+import type { TooltipInitialState, TooltipProps } from 'reakit';
+import type { ReactElement, ReactNode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { PopperProps } from '../utils/types';
+import type { ShortcutProps } from '../shortcut';
+
+export type Props = TooltipInitialState &
+ PopperProps &
+ Pick< ShortcutProps, 'shortcut' > & {
+ /**
+ * Determines if `Tooltip` has animations.
+ */
+ animated?: boolean;
+ /**
+ * The duration of `Tooltip` animations.
+ *
+ * @default 160
+ */
+ animationDuration?: boolean;
+ /**
+ * ID that will serve as a base for all the items IDs.
+ *
+ * @see https://reakit.io/docs/tooltip/#usetooltipstate
+ */
+ baseId?: string;
+ /**
+ * Content to render within the `Tooltip` floating label.
+ */
+ content?: ReactElement;
+ /**
+ * Spacing between the `Tooltip` reference and the floating label.
+ *
+ * @default 4
+ *
+ * @see https://reakit.io/docs/tooltip/#usetooltipstate
+ */
+ gutter?: number;
+ /**
+ * Whether or not the dialog should be rendered within `Portal`. It's true by default if modal is true.
+ *
+ * @default true
+ *
+ * @see https://reakit.io/docs/tooltip/#tooltip
+ */
+ modal?: boolean;
+ /**
+ * Whether `Tooltip` is visible.
+ *
+ * @default false
+ *
+ * @see https://reakit.io/docs/tooltip/#usetooltipstate
+ */
+ visible?: boolean;
+ children: ReactElement;
+ focusable?: boolean;
+ };
+
+export type ContentProps = TooltipProps & {
+ children: ReactNode;
+};
diff --git a/packages/components/src/ui/utils/types.ts b/packages/components/src/ui/utils/types.ts
index d0ea62501a1f6..538413204f781 100644
--- a/packages/components/src/ui/utils/types.ts
+++ b/packages/components/src/ui/utils/types.ts
@@ -1 +1,36 @@
export type ResponsiveCSSValue< T > = Array< T | undefined > | T;
+
+export type PopperPlacement =
+ | 'auto'
+ | 'auto-start'
+ | 'auto-end'
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end';
+
+export type PopperProps = {
+ /**
+ * Position of the popover element.
+ *
+ * @default 'auto'
+ *
+ * @see https://popper.js.org/docs/v1/#popperplacements--codeenumcode
+ */
+ placement?: PopperPlacement;
+};
+
+export type SizeRangeDefault =
+ | 'xLarge'
+ | 'large'
+ | 'medium'
+ | 'small'
+ | 'xSmall';
diff --git a/packages/components/src/utils/types.ts b/packages/components/src/utils/types.ts
new file mode 100644
index 0000000000000..54fe75edaa306
--- /dev/null
+++ b/packages/components/src/utils/types.ts
@@ -0,0 +1,36 @@
+export type SizeRangeDefault =
+ | 'xLarge'
+ | 'large'
+ | 'medium'
+ | 'small'
+ | 'xSmall';
+
+export type SizeRangeReduced = 'large' | 'medium' | 'small';
+
+export type PopperPlacement =
+ | 'auto'
+ | 'auto-start'
+ | 'auto-end'
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end';
+
+export type PopperProps = {
+ /**
+ * Position of the popover element.
+ *
+ * @default 'auto'
+ *
+ * @see https://popper.js.org/docs/v1/#popperplacements--codeenumcode
+ */
+ placement?: PopperPlacement;
+};
diff --git a/packages/components/src/utils/z-index.js b/packages/components/src/utils/z-index.js
new file mode 100644
index 0000000000000..600f51c673aac
--- /dev/null
+++ b/packages/components/src/utils/z-index.js
@@ -0,0 +1,2 @@
+export const Flyout = 10000;
+export const Tooltip = 1000002;