diff --git a/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap b/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap
new file mode 100644
index 0000000..4f3da53
--- /dev/null
+++ b/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap
@@ -0,0 +1,59 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Tooltip with createPortal > should not render the tooltip when hidden 1`] = `
+
+`;
+
+exports[`Tooltip with createPortal > should render the tooltip when visible 1`] = `
+
+
+
+
+ Label
+ :
+
+ This is a tooltip
+
+
+`;
diff --git a/src/components/tooltip/__tests__/tooltip.test.tsx b/src/components/tooltip/__tests__/tooltip.test.tsx
new file mode 100644
index 0000000..ff2b030
--- /dev/null
+++ b/src/components/tooltip/__tests__/tooltip.test.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import { vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/react'
+import { useTooltip } from '../use-tooltip'
+import { Button } from '../../button'
+import { Tooltip } from '../tooltip'
+
+// Mock createPortal for rendering Tooltip to the body
+vi.mock('react-dom', async () => {
+ const actual = await vi.importActual('react-dom')
+ return {
+ ...actual,
+ createPortal: (node: React.ReactNode) => node, // Render the node directly
+ }
+})
+
+describe('Tooltip with createPortal', () => {
+ it('should render the tooltip when visible', () => {
+ const TestComponent = () => {
+ const tooltip = useTooltip()
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ const { container, getByText } = render()
+ // Trigger visibility
+ const button = getByText('Hover me')
+ fireEvent.mouseEnter(button)
+
+ // Snapshot test with the tooltip visible
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should not render the tooltip when hidden', () => {
+ const TestComponent = () => {
+ const tooltip = useTooltip()
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ const { container } = render()
+ // Snapshot test with the tooltip hidden
+ expect(container).toMatchSnapshot()
+ })
+})
diff --git a/src/components/tooltip/__tests__/use-tooltip.test.tsx b/src/components/tooltip/__tests__/use-tooltip.test.tsx
new file mode 100644
index 0000000..0a3ac5d
--- /dev/null
+++ b/src/components/tooltip/__tests__/use-tooltip.test.tsx
@@ -0,0 +1,64 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { useTooltip } from '../use-tooltip'
+import { Button } from '../../button'
+import { Tooltip } from '../tooltip'
+import { describe, expect, test } from 'vitest'
+
+// Helper component to test hook behavior
+const TooltipTestComponent = () => {
+ const tooltip = useTooltip()
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+describe('useTooltip', () => {
+ test('should return tooltip and trigger props', () => {
+ const { result } = renderHook(() => useTooltip())
+
+ const triggerProps = result.current.getTriggerProps()
+ const tooltipProps = result.current.getTooltipProps()
+
+ expect(triggerProps).toHaveProperty('aria-describedby')
+ expect(triggerProps).toHaveProperty('onFocus')
+ expect(triggerProps).toHaveProperty('onBlur')
+ expect(triggerProps).toHaveProperty('onMouseEnter')
+ expect(triggerProps).toHaveProperty('onMouseLeave')
+
+ expect(tooltipProps).toHaveProperty('id')
+ expect(tooltipProps).toHaveProperty('role', 'tooltip')
+ expect(tooltipProps).toHaveProperty('aria-hidden', true)
+ })
+
+ test('should position the tooltip correctly', () => {
+ render()
+
+ const trigger = screen.getByText('Hover me')
+
+ fireEvent.mouseEnter(trigger)
+
+ const tooltip = screen.getByText('Tooltip description')
+
+ const tooltipStyle = window.getComputedStyle(tooltip)
+ expect(tooltipStyle.position).toBe('absolute')
+ // Result for top and left can be negative as we haven't added support for the overflowing tooltip over viewport
+ // Once support is added remove '-' from the match below
+ expect(tooltipStyle.transform).toMatch(/translate\(-?\d+px, -?\d+px\)/)
+ })
+
+ test('should clean up event listeners on unmount', () => {
+ const { unmount } = render()
+
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
+
+ unmount()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
+ removeEventListenerSpy.mockRestore()
+ })
+})
diff --git a/src/components/tooltip/index.ts b/src/components/tooltip/index.ts
new file mode 100644
index 0000000..8c3f7a0
--- /dev/null
+++ b/src/components/tooltip/index.ts
@@ -0,0 +1,2 @@
+export * from './styles'
+export * from './tooltip'
diff --git a/src/components/tooltip/styles.ts b/src/components/tooltip/styles.ts
new file mode 100644
index 0000000..18edd46
--- /dev/null
+++ b/src/components/tooltip/styles.ts
@@ -0,0 +1,34 @@
+import { styled } from '@linaria/react'
+
+export const ElTooltip = styled.div`
+ width: max-content;
+ background: var(--fill-default-darkest, #222b33);
+ color: var(--text-white);
+ padding: var(--spacing-2) var(--spacing-3) var(--spacing-2) var(--spacing-3);
+ border-radius: var(--corner-default);
+ font-family: var(--font-family);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-xs);
+ letter-spacing: var(--letter-spacing-xs);
+ text-align: left;
+ position: absolute;
+ transition: opacity 0.2s;
+ pointer-events: none;
+ white-space: normal;
+ word-wrap: break-word;
+ z-index: 99999; // Adding additional CSS to position tooltip on top of everything
+
+ // To do (In Future): Tooltip Positioning to be replaced with css anchor positioning feature
+ // Currently not supported by wider browser group
+ // See: https://developer.mozilla.org/en-US/docs/Web/CSS/position-anchor#browser_compatibility
+`
+
+export const ElTooltipLabel = styled.span`
+ font-family: var(--font-family);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-xs);
+ letter-spacing: var(--letter-spacing-xs);
+ text-align: left;
+`
diff --git a/src/components/tooltip/tooltip.mdx b/src/components/tooltip/tooltip.mdx
new file mode 100644
index 0000000..23e24d1
--- /dev/null
+++ b/src/components/tooltip/tooltip.mdx
@@ -0,0 +1,16 @@
+import { Meta, Canvas, Controls, Description } from '@storybook/blocks'
+import { RenderHtmlMarkup } from '../../storybook/render-html-markup'
+import * as TooltipStories from './tooltip.stories'
+
+
+
+# Tooltip
+
+
+
+
+
+
+
+
+
diff --git a/src/components/tooltip/tooltip.stories.tsx b/src/components/tooltip/tooltip.stories.tsx
new file mode 100644
index 0000000..7523f5b
--- /dev/null
+++ b/src/components/tooltip/tooltip.stories.tsx
@@ -0,0 +1,157 @@
+import { Meta } from '@storybook/react'
+import { Button } from '../button'
+import { useTooltip } from './use-tooltip'
+import { Tooltip, TooltipProps } from './tooltip'
+import { ElTooltip, ElTooltipLabel } from './styles'
+
+const meta: Meta = {
+ title: 'Components/Tooltip',
+ component: Tooltip,
+ argTypes: {
+ description: {
+ control: 'text',
+ description: 'Defines the description of the tooltip.',
+ },
+ label: {
+ control: 'text',
+ description: 'Defines the label of the tooltip',
+ },
+ position: {
+ control: 'select',
+ options: [
+ 'top',
+ 'bottom',
+ 'right',
+ 'left',
+ 'top-start',
+ 'top-end',
+ 'bottom-start',
+ 'bottom-end',
+ 'right-start',
+ 'right-end',
+ 'left-start',
+ 'left-end',
+ ],
+ description: 'Defines where to position of the tooltip relative to the trigger.',
+ table: {
+ type: { summary: 'enum' },
+ defaultValue: { summary: 'top' },
+ },
+ },
+ maxWidth: {
+ control: 'text',
+ description: 'Defines the max-width of the tooltip container.',
+ table: {
+ type: { summary: 'string', detail: 'CSS max-width value (e.g., 400px, 50%)' },
+ defaultValue: { summary: '400px' },
+ },
+ },
+ },
+}
+
+export default meta
+
+/**
+ * The Tooltip component is a lightweight UI element used to display contextual
+ * information when a user hovers over or focuses on a target element.
+ * In the default configuration, the tooltip provides a brief label and/or description,
+ * positioned to the top relative to the target,
+ * with customizable properties such as label, description, position, and maxWidth.
+ *
+ * The component utilizes the `useTooltip` hook, which simplifies the integration of tooltip behavior
+ * by providing properties and methods required to manage the tooltip and its trigger seamlessly.
+ *
+ * The `useTooltip` hook provides two keys props:
+ *
+ * `getTriggerProps()`: Applied to the trigger element (e.g., a Button or an Icon, etc)
+ * to bind the necessary event handlers for displaying the tooltip.
+ *
+ * `getTooltipProps()`: Applied to the Tooltip component to manage its visibility, positioning, and accessibility.
+ */
+export const BasicUsage = {
+ args: {
+ description: 'Tooltip text', // Default value for description
+ label: 'Label', // Default value for label
+ },
+ // This is to style the story, as the story parent container has overflow hidden.
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ render: (args: any) => {
+ const tooltip = useTooltip()
+
+ return (
+ <>
+
+
+ >
+ )
+ },
+ parameters: {
+ docs: {
+ source: {
+ code: `
+render: () => {
+ const tooltip = useTooltip()
+ return (
+
+
+ )
+}
+ `,
+ },
+ },
+ },
+}
+
+// Mock createPortal for this story
+const MockedTooltip = (props: TooltipProps) => {
+ const content = (
+
+ {props.label && {props.label}: }
+ {props.description}
+
+ )
+
+ return content
+}
+
+/**
+ * Below is the UI representation for the tooltip component without the trigger.
+ */
+export const DisplayTooltipWithoutTrigger = {
+ args: {
+ description: 'Tooltip text', // Default value for description
+ label: 'Label', // Default value for label
+ position: 'top',
+ maxWidth: '400px',
+ isVisible: true,
+ },
+ render: (args) => {
+ console.log('args', args)
+ // Pass args to MockedTooltip to make it reactive
+ return
+ },
+ parameters: {
+ docs: {
+ canvas: {
+ sourceState: 'none',
+ },
+ story: {
+ inline: false,
+ iframeHeight: 60,
+ },
+ },
+ },
+}
diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx
new file mode 100644
index 0000000..5d97e70
--- /dev/null
+++ b/src/components/tooltip/tooltip.tsx
@@ -0,0 +1,43 @@
+import React, { FC, HTMLAttributes } from 'react'
+import { ElTooltip, ElTooltipLabel } from './styles'
+import { createPortal } from 'react-dom'
+
+export interface TooltipProps extends HTMLAttributes {
+ label?: string
+ description: string
+ isVisible?: boolean
+ maxWidth?: string
+ position?:
+ | 'top'
+ | 'bottom'
+ | 'right'
+ | 'left'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'right-start'
+ | 'right-end'
+ | 'left-start'
+ | 'left-end'
+}
+
+export const Tooltip: FC = ({
+ isVisible,
+ label,
+ description,
+ maxWidth = '400px',
+ position = 'top',
+ ...rest
+}) => {
+ const tooltip = (
+
+ {label && {label}: }
+ {description}
+
+ )
+ if (!isVisible) {
+ return null
+ }
+ return createPortal(tooltip, document.body)
+}
diff --git a/src/components/tooltip/use-tooltip.ts b/src/components/tooltip/use-tooltip.ts
new file mode 100644
index 0000000..afdc543
--- /dev/null
+++ b/src/components/tooltip/use-tooltip.ts
@@ -0,0 +1,137 @@
+import { HTMLAttributes, useEffect, useState } from 'react'
+import { useId } from '#src/storybook/random-id'
+
+export const useTooltip = () => {
+ const tooltipId = `tooltip-id-${useId()}`
+ const [isVisible, setIsVisible] = useState(false)
+
+ const show = () => setIsVisible(true)
+ const hide = () => setIsVisible(false)
+
+ const positionTooltip = () => {
+ // To do (In Future): Tooltip/Trigger selector to use useRef once project upgraded to React 19
+ // See: https://react.dev/reference/react/forwardRef
+ // See: https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop
+ const tooltip = document.querySelector(`[id="${tooltipId}"]`)
+ const trigger = document.querySelector(`[data-visible-id="${tooltipId}"]`)
+
+ if (!tooltip || !trigger) return
+
+ const triggerRect = trigger.getBoundingClientRect()
+ const tooltipRect = tooltip.getBoundingClientRect()
+
+ // Calculate scroll offsets
+ const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
+ const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft
+
+ // const viewportWidth = window.innerWidth
+ // const viewportHeight = window.innerHeight
+ const position = tooltip.getAttribute('data-position')
+
+ let top = 0
+ let left = 0
+
+ switch (position) {
+ case 'top':
+ top = triggerRect.top + scrollTop - tooltipRect.height - 4
+ left = triggerRect.left + scrollLeft + (triggerRect.width - tooltipRect.width) / 2
+ break
+ case 'top-start':
+ top = triggerRect.top + scrollTop - tooltipRect.height - 4
+ left = triggerRect.left + scrollLeft
+ break
+ case 'top-end':
+ top = triggerRect.top + scrollTop - tooltipRect.height - 4
+ left = triggerRect.right + scrollLeft - tooltipRect.width
+ break
+ case 'bottom':
+ top = triggerRect.bottom + scrollTop + 4
+ left = triggerRect.left + scrollLeft + (triggerRect.width - tooltipRect.width) / 2
+ break
+ case 'bottom-start':
+ top = triggerRect.bottom + scrollTop + 4
+ left = triggerRect.left + scrollLeft
+ break
+ case 'bottom-end':
+ top = triggerRect.bottom + scrollTop + 4
+ left = triggerRect.right + scrollLeft - tooltipRect.width
+ break
+ case 'left':
+ top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2
+ left = triggerRect.left + scrollLeft - tooltipRect.width - 4
+ break
+ case 'left-start':
+ top = triggerRect.top + scrollTop
+ left = triggerRect.left + scrollLeft - tooltipRect.width - 4
+ break
+ case 'left-end':
+ top = triggerRect.bottom + scrollTop - tooltipRect.height
+ left = triggerRect.left + scrollLeft - tooltipRect.width - 4
+ break
+ case 'right':
+ top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2
+ left = triggerRect.right + scrollLeft + 4
+ break
+ case 'right-start':
+ top = triggerRect.top + scrollTop
+ left = triggerRect.right + scrollLeft + 4
+ break
+ case 'right-end':
+ top = triggerRect.bottom + scrollTop - tooltipRect.height
+ left = triggerRect.right + scrollLeft + 4
+ break
+ default:
+ break
+ }
+
+ // Below are inline style applied to tooltip to position it relevant to respective trigger
+ tooltip.style.position = 'absolute'
+ tooltip.style.inset = '0px auto auto 0px'
+ tooltip.style.transform = `translate(${left}px, ${top}px)`
+ tooltip.style.margin = '0px'
+ }
+
+ // Update tooltip position on visibility change or window resize
+ useEffect(() => {
+ if (isVisible) {
+ positionTooltip()
+ window.addEventListener('resize', positionTooltip)
+ }
+
+ return () => {
+ window.removeEventListener('resize', positionTooltip)
+ }
+ }, [isVisible])
+
+ const getTriggerProps = (props?: HTMLAttributes) => ({
+ ...props,
+ 'data-visible-id': tooltipId,
+ 'aria-describedby': tooltipId,
+ onFocus: (e: React.FocusEvent) => {
+ props?.onFocus?.(e)
+ show()
+ },
+ onBlur: (e: React.FocusEvent) => {
+ props?.onBlur?.(e)
+ hide()
+ },
+ onMouseEnter: (e: React.MouseEvent) => {
+ props?.onMouseEnter?.(e)
+ show()
+ },
+ onMouseLeave: (e: React.MouseEvent) => {
+ props?.onMouseLeave?.(e)
+ hide()
+ },
+ })
+
+ const getTooltipProps = (props?: HTMLAttributes) => ({
+ ...props,
+ id: tooltipId,
+ role: 'tooltip',
+ 'aria-hidden': !isVisible,
+ isVisible,
+ })
+
+ return { getTriggerProps, getTooltipProps }
+}
diff --git a/src/index.ts b/src/index.ts
index 510cf07..0411344 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -58,6 +58,7 @@ export * from './components/menu'
export * from './components/dialog'
export * from './components/avatar'
export * from './components/skeleton'
+export * from './components/tooltip'
export * from './components/table/table-container'
export * from './components/table/table-toolbar'