# Tooltip

Contextual hint on hover or focus - keep to 80 chars.

Preview: https://design.freecodecamp.org/playground#tooltip

## Add to your project

Use React and TypeScript. Required packages: `react@>=18 <20`. No freeCodeCamp package is needed.

Copy the files below to the indicated paths, relative to your project root. If you change the layout, update relative imports too.

Import the CSS once from your application entry. For an entry in src/:

```ts
import './ui/theme/tokens.css';
import './ui/theme/base.css';
import './ui/tooltip/tooltip.css';
import './ui/button/button.css';
```

The theme is shared: reuse it if already installed. Fonts use /fonts/ URLs on your host. Download the font files listed in https://design.freecodecamp.org/registry/starter.md or change those URLs in your copied tokens.css.

## Example

```tsx
import { useState } from 'react';
import { Tooltip } from './ui/tooltip/Tooltip';
import { Button } from './ui/button/Button';

export function Example() {
  const [complete, setComplete] = useState(false);
  return (
    <div style={{ display: 'grid', gap: 16 }}>
      <Tooltip content='Runs the public test suite against your code.'>
        <Button onClick={() => setComplete(true)}>Run tests</Button>
      </Tooltip>
      {complete && <p role='status'>Example tests passed.</p>}
    </div>
  );
}
```

## Interaction guidance

Review https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ and test keyboard operation in your project.

## Component source

### src/ui/tooltip/Tooltip.tsx

Source: https://design.freecodecamp.org/registry/tooltip/Tooltip.tsx

```tsx
import React, {
  cloneElement,
  isValidElement,
  useEffect,
  useId,
  useState
} from 'react';

export interface TooltipProps {
  content: React.ReactNode;
  children: React.ReactNode;
  className?: string;
}

export const Tooltip = ({
  content,
  children,
  className = ''
}: TooltipProps) => {
  const classes = ['tip', className].filter(Boolean).join(' ');
  const id = useId();
  const [dismissed, setDismissed] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  useEffect(() => {
    if ((!hovered && !focused) || dismissed) return;
    const dismiss = ({ key }: KeyboardEvent) => {
      if (key === 'Escape') setDismissed(true);
    };
    document.addEventListener('keydown', dismiss);
    return () => document.removeEventListener('keydown', dismiss);
  }, [hovered, focused, dismissed]);
  const trigger = isValidElement<React.HTMLAttributes<HTMLElement>>(
    children
  ) ? (
    cloneElement(children, {
      'aria-describedby': [children.props['aria-describedby'], id]
        .filter(Boolean)
        .join(' '),
      tabIndex: children.props.tabIndex ?? 0
    })
  ) : (
    <span tabIndex={0} aria-describedby={id}>
      {children}
    </span>
  );
  return (
    <span
      className={classes}
      data-dismissed={dismissed ? '' : undefined}
      onMouseEnter={() => {
        setHovered(true);
        setDismissed(false);
      }}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => {
        setFocused(true);
        setDismissed(false);
      }}
      onBlur={event => {
        if (!event.currentTarget.contains(event.relatedTarget))
          setFocused(false);
      }}
    >
      {trigger}
      <span id={id} role='tooltip' className='tip__bubble'>
        {content}
      </span>
    </span>
  );
};
Tooltip.displayName = 'Tooltip';
```

### src/ui/tooltip/tooltip.css

Source: https://design.freecodecamp.org/registry/tooltip/tooltip.css

```css
.tip {
  position: relative;
  display: inline-flex;
}
.tip__bubble {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 8px);
  transform: translateX(-50%);
  padding: 4px 10px;
  background: var(--foreground-primary);
  color: var(--background-primary);
  font: 500 13px/1.2 var(--font-mono);
  width: max-content;
  max-width: min(280px, calc(100vw - 64px));
  white-space: normal;
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms;
}
.tip:hover .tip__bubble,
.tip:focus-within .tip__bubble {
  opacity: 1;
}
.tip[data-dismissed] .tip__bubble {
  opacity: 0;
}
```

## Shared source: Theme

### src/ui/theme/tokens.css

Source: https://design.freecodecamp.org/registry/theme/tokens.css

```css
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Light.woff') format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Italic.woff') format('woff');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-BoldItalic.woff') format('woff');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff') format('woff');
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-BoldItalic.woff2') format('woff2');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

:root {
  --gray-00: #ffffff;
  --gray-00-translucent: rgba(255, 255, 255, 0.85);
  --gray-05: #f5f6f7;
  --gray-10: #dfdfe2;
  --gray-15: #d0d0d5;
  --gray-45: #858591;
  --gray-75: #3b3b4f;
  --gray-80: #2a2a40;
  --gray-85: #1b1b32;
  --gray-90: #0a0a23;
  --gray-90-translucent: rgba(10, 10, 35, 0.85);

  --purple-light: #dbb8ff;
  --purple-mid: #9400d3;
  --purple-dark: #5a01a7;
  --yellow-light: #ffc300;
  --yellow-gold: #ffbf00;
  --yellow-style: #f1be32;
  --yellow-dark: #4d3800;
  --blue-light: #99c9ff;
  --blue-light-translucent: rgba(153, 201, 255, 0.3);
  --blue-mid: #198eee;
  --blue-dark: #002ead;
  --blue-dark-translucent: rgba(0, 46, 173, 0.3);
  --green-light: #acd157;
  --green-dark: #00471b;
  --red-light: #ffadad;
  --red-dark: #850000;
  --love-light: #f8577c;
  --love-dark: #f82153;
  --orange: #eda971;

  --editor-background-light: #fffffe;
  --editor-background-dark: #2a2b40;

  --syntax-keyword: #dbb8ff;
  --syntax-fn: #99c9ff;
  --syntax-string: #acd157;
  --syntax-class: #f1be32;
  --syntax-number: #f78c6c;
  --syntax-tag: #f07178;
  --syntax-operator: #89ddff;
  --syntax-invalid: #ff5370;
  --syntax-comment: #858591;
  --syntax-plain: #eeffff;

  --font-sans:
    'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;

  --fs-base: 18px;
  --fs-sm: 16px;
  --fs-md: 18px;
  --fs-lg: 24px;
  --fs-xl: 32px;
  --fs-2xl: 42px;
  --fs-3xl: 56px;
  --fs-display: clamp(2.5rem, 5vw, 3.75rem);

  --lh-tight: 1.2;
  --lh-snug: 1.33;
  --lh-base: 1.42857143;
  --lh-loose: 1.6;

  --fw-light: 300;
  --fw-regular: 400;
  --fw-bold: 700;
  --fw-black: 900;

  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --border-width-hair: 1px;
  --border-width-default: 2px;
  --border-width-thick: 3px;
  --radius-none: 0;
  --radius-sm: 2px;

  --focus-outline-color: var(--blue-mid);
  --focus-outline-width: 3px;

  --z-breadcrumbs: 100;
  --z-flash: 150;
  --z-site-header: 200;
  --z-modal: 1050;

  --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast: 120ms;
  --dur-base: 180ms;
  --dur-slow: 260ms;

  --header-height: 48px;
  --breadcrumbs-height: 32px;
  --sidebar-width: 288px;
  --content-max: 1040px;

  color-scheme: dark;
}

.dark-palette,
:root {
  color-scheme: dark;
  --foreground-primary: var(--gray-00);
  --foreground-secondary: var(--gray-05);
  --foreground-tertiary: var(--gray-10);
  --foreground-quaternary: var(--gray-15);
  --foreground-muted: #b0b0bd;

  --background-primary: var(--gray-90);
  --background-primary-translucent: var(--gray-90-translucent);
  --background-secondary: var(--gray-85);
  --background-tertiary: #33334f;
  --background-quaternary: #4b4b66;

  --highlight-color: var(--blue-light);
  --highlight-background: var(--blue-dark);
  --selection-color: var(--blue-light-translucent);

  --success-color: var(--green-light);
  --success-background: var(--green-dark);
  --danger-color: var(--red-light);
  --danger-background: var(--red-dark);
  --warning-color: var(--yellow-light);
  --warning-background: var(--yellow-dark);
  --purple-color: var(--purple-light);
  --purple-background: var(--purple-dark);
  --love-color: var(--love-light);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(255, 255, 255, 0.045);
  --surface-elevation-2: rgba(255, 255, 255, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);
}

.light-palette {
  --foreground-primary: var(--gray-90);
  --foreground-secondary: var(--gray-85);
  --foreground-tertiary: var(--gray-80);
  --foreground-quaternary: var(--gray-75);
  --foreground-muted: #5a5a68;

  --background-primary: var(--gray-00);
  --background-primary-translucent: var(--gray-00-translucent);
  --background-secondary: var(--gray-05);
  --background-tertiary: #c5c5cc;
  --background-quaternary: #a8a8b4;

  --highlight-color: var(--blue-dark);
  --highlight-background: var(--blue-light);
  --selection-color: var(--blue-dark-translucent);

  --success-color: var(--green-dark);
  --success-background: var(--green-light);
  --danger-color: var(--red-dark);
  --danger-background: var(--red-light);
  --warning-color: var(--yellow-dark);
  --warning-background: var(--yellow-light);
  --purple-color: var(--purple-dark);
  --purple-background: var(--purple-light);
  --love-color: var(--love-dark);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(10, 10, 35, 0.05);
  --surface-elevation-2: rgba(10, 10, 35, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--fs-md);
  font-family: var(--font-sans);
  line-height: var(--lh-base);
  color: var(--foreground-primary);
  background: var(--background-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + 24px);
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--foreground-primary);
  background: var(--background-primary);
}

::selection {
  background: var(--selection-color);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  line-height: var(--lh-snug);
  margin: 0 0 12px 0;
}
h1 {
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}
h2 {
  font-size: var(--fs-2xl);
  letter-spacing: -0.005em;
}
h3 {
  font-size: var(--fs-xl);
}
h4 {
  font-size: var(--fs-lg);
}
h5 {
  font-size: var(--fs-md);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
h6 {
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--foreground-muted);
  font-family: var(--font-mono);
}

p {
  margin: 0 0 12px 0;
}

a {
  color: var(--highlight-color);
  text-decoration: underline;
  text-underline-position: under;
  text-underline-offset: 0.1em;
}
a:hover {
  color: var(--foreground-primary);
}

code,
pre,
kbd,
samp {
  font-family: var(--font-mono);
  font-size: 16px;
}
code {
  background: var(--background-tertiary);
  color: var(--foreground-tertiary);
}
:not(pre) > code {
  border: 1px solid var(--background-quaternary);
  padding: 1px 4px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
pre {
  background: var(--editor-background);
  color: var(--foreground-tertiary);
  padding: 14px 16px;
  font-size: 14px;
  line-height: var(--lh-base);
  max-width: 100%;
  overflow-x: auto;
  margin: 0;
}
pre code {
  display: block;
  width: max-content;
  min-width: 100%;
  background: transparent;
  border: 0;
  padding: 0;
}

:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 0;
}

hr {
  border: 0;
  border-top: 1px solid var(--background-quaternary);
  margin: 24px 0;
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
  background: var(--background-quaternary);
  border: 2px solid var(--background-primary);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--foreground-muted);
}
```

### src/ui/theme/base.css

Source: https://design.freecodecamp.org/registry/theme/base.css

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## Shared source: button

### src/ui/button/Button.tsx

Source: https://design.freecodecamp.org/registry/button/Button.tsx

```tsx
import React, { forwardRef } from 'react';

export type ButtonVariant =
  | 'default'
  | 'cta'
  | 'danger'
  | 'info'
  | 'ghost'
  | 'link';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  block?: boolean;
  isLoading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'default',
      size = 'md',
      block = false,
      isLoading = false,
      className = '',
      disabled,
      children,
      ...rest
    },
    ref
  ) => {
    const classes = [
      'btn',
      variant !== 'default' && `btn--${variant}`,
      size !== 'md' && `btn--${size}`,
      block && 'btn--block',
      className
    ]
      .filter(Boolean)
      .join(' ');
    return (
      <button
        ref={ref}
        className={classes}
        disabled={disabled || isLoading}
        aria-busy={isLoading ? true : undefined}
        {...rest}
      >
        {isLoading && <span className='btn__spinner' aria-hidden='true' />}
        {children}
      </button>
    );
  }
);
Button.displayName = 'Button';
```

### src/ui/button/button.css

Source: https://design.freecodecamp.org/registry/button/button.css

```css
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  font-weight: var(--fw-regular);
  line-height: var(--lh-base);
  padding: 6px 14px;
  border: var(--border-width-thick) solid var(--foreground-secondary);
  background: var(--background-quaternary);
  color: var(--foreground-secondary);
  text-decoration: none;
  cursor: pointer;
  transition:
    background-color 120ms,
    color 120ms,
    border-color 120ms;
  position: relative;
}
.btn:hover {
  background: var(--foreground-primary);
  color: var(--background-primary);
}
.btn:active::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--gray-90);
  opacity: 0.2;
}
.btn:disabled,
.btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn--cta {
  background: var(--cta-background);
  color: var(--cta-foreground);
  border-color: var(--cta-background);
}
.btn--cta:hover {
  background: var(--cta-background);
  color: var(--cta-foreground);
  filter: brightness(1.08);
}

.btn--danger {
  border-color: var(--danger-color);
  background: var(--danger-background);
  color: var(--danger-color);
}
.btn--danger:hover {
  background: var(--danger-color);
  color: var(--danger-background);
}

.btn--info {
  border-color: var(--highlight-color);
  background: var(--highlight-background);
  color: var(--highlight-color);
}
.btn--info:hover {
  background: var(--highlight-color);
  color: var(--highlight-background);
}

.btn--ghost {
  border-color: transparent;
  background: transparent;
  color: var(--foreground-secondary);
}
.btn--ghost:hover {
  background: var(--background-tertiary);
  color: var(--foreground-primary);
}

.btn--link {
  border-color: transparent;
  background: transparent;
  color: var(--highlight-color);
  text-decoration: underline;
  padding: 2px 4px;
}
.btn--link:hover {
  background: transparent;
  color: var(--foreground-primary);
}

.btn--sm {
  padding: 4px 10px;
  font-size: var(--fs-sm);
  border-width: var(--border-width-default);
}
.btn--lg {
  padding: 10px 18px;
  font-size: var(--fs-lg);
}
.btn--block {
  display: flex;
  width: 100%;
}

.btn[aria-busy='true'] {
  cursor: progress;
}
.btn__spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: fcc-spin 800ms linear infinite;
  vertical-align: -0.125em;
}
@keyframes fcc-spin {
  to {
    transform: rotate(360deg);
  }
}
```

## Adapting this component

Keep the component's semantics and keyboard behavior. Use the CSS variables to change its appearance. Check the result in your project; copied source does not receive automatic updates.

Source revision: 37aae52 (2026-09-08). Component source: BSD-3-Clause. Preserve the license notice: https://design.freecodecamp.org/license.txt.

Design rules: https://design.freecodecamp.org/handbook.md

