Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
CommunicationUpdatedFreeWCAG 2.2 AA

Tooltip

Hover/focus-triggered help bubble — 9 positions × 5 sizes.

Preview

Live preview
@oshon-ai/components

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add tooltip

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Tooltip } from '@oshon-ai/components';

export default function Example() {
  return <Tooltip />;
}

Position × Alignment — 8 Figma variants

Position = Top · Alignment ∈ { Left, Center, Right }

Top · Left
Top · Center
Top · Right

Position = Bottom · Alignment ∈ { Left, Center, Right }

Bottom · Left
Bottom · Center
Bottom · Right

Position = Side · Alignment ∈ { Left, Right } (Side+Center is not authored in Figma)

Side · Left
Side · Right
tsx
<div style={{ ...col, gap: '1.5rem' }}>
      <h3 style={heading}>
        Position = Top · Alignment ∈ &#123; Left, Center, Right &#125;
      </h3>
      <div style={row}>
        {TOP_CELLS.map((c) => (
          <MatrixCell key={c.label} {...c} />
        ))}
      </div>
      <h3 style={heading}>
        Position = Bottom · Alignment ∈ &#123; Left, Center, Right &#125;
      </h3>
      <div style={row}>
        {BOTTOM_CELLS.map((c) => (
          <MatrixCell key={c.label} {...c} />
        ))}
      </div>
      <h3 style={heading}>
        Position = Side · Alignment ∈ &#123; Left, Right &#125; (Side+Center
        is not authored in Figma)
      </h3>
      <div style={row}>
        {SIDE_CELLS.map((c) => (
          <MatrixCell key={c.label} {...c} />
        ))}
      </div>
    </div>

Size matrix — five-axis QA

Top·Center anchor — every size. Figma anchors m; xs / s tighten pad + drop to 11px, l relaxes pad + bumps to 13px, mobile mirrors m.

size="xs"
size="s"
size="m"
size="l"
size="mobile"
tsx
<div style={{ ...col, gap: '1rem' }}>
      <h3 style={heading}>
        Top·Center anchor — every size. Figma anchors{' '}
        <code>m</code>; xs / s tighten pad + drop to 11px, l relaxes
        pad + bumps to 13px, mobile mirrors m.
      </h3>
      <div style={row}>
        {SIZES.map((size) => (
          <div key={size} style={col}>
            <div style={caption}>size=&quot;{size}&quot;</div>
            <div style={stage}>
              <Tooltip.Provider delayDuration={0}>
                <Tooltip.Root defaultOpen>
                  <Tooltip.Trigger asChild>
                    <button
                      type="button"
                      aria-label={`Tooltip ${size}`}
                      style={{
                        width: 16,
                        height: 16,
                        border: 'none',
                        background: 'transparent',
                        padding: 0,
                        cursor: 'default',
                      }}
                    >
                      <TriggerDot />
                    </button>
                  </Tooltip.Trigger>
                  <Tooltip.Portal>
                    <Tooltip.Content size={size} side="top" align="center">
                      Help text at {size}
                      <Tooltip.Arrow />
                    </Tooltip.Content>
                  </Tooltip.Portal>
                </Tooltip.Root>
              </Tooltip.Provider>
            </div>
          </div>
        ))}
      </div>
    </div>

Usage — info-icon trigger after a sentence

Real-world layout: a sentence followed by an info icon. Aligning the tooltip to the trigger's side keeps the chip from spilling off the inline edge of the paragraph.

Alignment=Left

This is a sentence that might need more explanation.

Alignment=Right

This is a sentence that might need more explanation.

tsx
<div style={{ ...col, gap: '1rem' }}>
      <h3 style={heading}>
        Real-world layout: a sentence followed by an info icon. Aligning
        the tooltip to the trigger&apos;s side keeps the chip from spilling
        off the inline edge of the paragraph.
      </h3>
      <div style={row}>
        <Sentence align="start" label="left" />
        <Sentence align="end" label="right" />
      </div>
    </div>

Arrow — crisp SVG, 4× zoom

Figma rejected its own rasterized 32×6 PNG asset (page-level callout). The arrow below is a 12×6 SVG triangle — at 4× zoom edges should remain straight with no anti-alias halo.

tsx
<div style={{ ...col, gap: '1rem' }}>
      <h3 style={heading}>
        Figma rejected its own rasterized 32×6 PNG asset (page-level
        callout). The arrow below is a 12×6 SVG triangle — at 4× zoom
        edges should remain straight with no anti-alias halo.
      </h3>
      <div style={{ ...stage, minHeight: 200, padding: 64 }}>
        <div
          style={{
            transform: 'scale(4)',
            transformOrigin: 'center',
          }}
        >
          <Tooltip.Provider delayDuration={0}>
            <Tooltip.Root defaultOpen>
              <Tooltip.Trigger asChild>
                <button
                  type="button"
                  aria-label="Arrow crispness"
                  style={{
                    width: 12,
                    height: 12,
                    border: 'none',
                    background: 'transparent',
                    padding: 0,
                    cursor: 'default',
                  }}
                >
                  <TriggerDot />
                </button>
              </Tooltip.Trigger>
              <Tooltip.Portal>
                <Tooltip.Content side="top" align="center">
                  Crisp
                  <Tooltip.Arrow />
                </Tooltip.Content>
              </Tooltip.Portal>
            </Tooltip.Root>
          </Tooltip.Provider>
        </div>
      </div>
    </div>

Hover & focus — default 700ms delay

Hover or Tab to each trigger to see the side-specific enter animation. Esc closes; hovering away closes after the default delay. Triggers near the viewport edge demonstrate Radix's collision flipping.

tsx
<Tooltip.Provider>
      <div style={{ ...col, gap: '1rem' }}>
        <h3 style={heading}>
          Hover or Tab to each trigger to see the side-specific enter
          animation. Esc closes; hovering away closes after the default
          delay. Triggers near the viewport edge demonstrate Radix&apos;s
          collision flipping.
        </h3>
        <div style={{ ...stage, minHeight: 240, minWidth: 640, padding: 80 }}>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(3, 1fr)',
              gridTemplateRows: 'repeat(2, auto)',
              gap: 96,
              placeItems: 'center',
              width: '100%',
            }}
          >
            {(
              [
                { side: 'top', align: 'start', label: 'Top L' },
                { side: 'top', align: 'center', label: 'Top C' },
                { side: 'top', align: 'end', label: 'Top R' },
                { side: 'bottom', align: 'start', label: 'Bot L' },
                { side: 'left', align: 'center', label: 'Left' },
                { side: 'right', align: 'center', label: 'Right' },
              ] as const
            ).map(({ side, align, label }) => (
              <Tooltip.Root key={label}>
                <Tooltip.Trigger asChild>
                  <button
                    type="button"
                    style={{
                      padding: '6px 10px',
                      border:
                        '1px solid var(--oshon-color-neutral-300, #dfe2e2)',
                      borderRadius: 6,
                      background: 'var(--oshon-color-surface, #fff)',
                      fontFamily: 'var(--oshon-font-family, system-ui)',
                      fontSize: 12,
                      cursor: 'pointer',
                    }}
                  >
                    {label}
                  </button>
                </Tooltip.Trigger>
                <Tooltip.Portal>
                  <Tooltip.Content side={side} align={align}>
                    {`Hover/focus revealed (${side}/${align})`}
                    <Tooltip.Arrow />
                  </Tooltip.Content>
                </Tooltip.Portal>
              </Tooltip.Root>
            ))}
          </div>
        </div>
      </div>
    </Tooltip.Provider>

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Tooltip
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

Provider

Configures default delay + skip-delay behavior for every descendant Tooltip. Mount once near the app root.

Trigger

Element that opens the tooltip on hover/focus. Pass `asChild` (Radix pass-through) to compose on top of another control — typical pattern.

Content

The tooltip surface. Takes `size="xs|s|m|l|mobile"` for the 5-size axis, plus Radix `side` (Position) and `align` (Alignment) props that compose into the 8 Figma variants.

Arrow

Crisp 12×6 SVG triangle matching the chip surface. Replaces the rasterized 32×6 PNG asset Figma authored — the file's own page-level callout asks for a smaller, non-rasterized pointer. Width/height overridable.

Keyboard

Tab/Shift+Tab focuses the trigger, which opens the tooltip. Escape closes. Hover + keyboard focus both surface the content (SC 1.4.13 hover/focus dismissible). Behavior inherited from @radix-ui/react-tooltip via @oshon-ai/primitives/tooltip.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-29

Do / Don't

✓ Do

Basic
<Tooltip.Provider>
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <button>?</button>
    </Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content side="top" align="center">
        Inline help.
        <Tooltip.Arrow />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>
Left-aligned (arrow pinned to start)
<Tooltip.Content side="top" align="start">
  This is a sentence that might need more explanation.
  <Tooltip.Arrow />
</Tooltip.Content>
Side variant (Figma "Side" = Radix `side="right"`)
<Tooltip.Content side="right" align="center">
  Tooltip text
  <Tooltip.Arrow />
</Tooltip.Content>
Permission-gated
<Tooltip.Root permissions={{ can: () => canViewHelp }} resource="field:ssn-help">
  …
</Tooltip.Root>

✗ Don't

Using Tooltip for interactive content
<Tooltip.Content><button onClick={fn}>Click</button></Tooltip.Content>

Tooltips close on hover-out and are not keyboard-reachable from inside. Use Popover for any interactive surface.

Tooltip without a Provider
<Tooltip.Root><Tooltip.Trigger>?</Tooltip.Trigger></Tooltip.Root>

Radix requires Provider to share delay state across sibling tooltips. Mount one Provider at the app root.

Reaching for the rasterized arrow asset
<img src="/figma-arrow-32x6.png" />

The Figma file itself rejects this — page-level callout reads "Pointer should be smaller, and should not have rasterized appearance." Use `<Tooltip.Arrow />` which renders a crisp 12×6 SVG triangle filled with the chip surface color.

Design rationale

Phase 4b overlay surface, reworked per the Figma page-level callout that explicitly rejected the prior implementation. Surface is `--gray-900` chip with 12px Lato body and a 6px corner radius — proprietary Oshon (§8.13), not a Shadcn/Material default. The 8-variant Position×Alignment matrix collapses onto Radix's native `side`+`align` props, so we get free collision-detection + flip behavior without a hand-rolled positioner. Arrow is a small crisp SVG (Radix's built-in <polygon>, sized 12×6) — the Figma asset was a 32×6 PNG, which the file's own callout asks to replace. Inline keyframes (ADR-001) deliver side-specific enter motion keyed off Radix `data-side`; reduced-motion is honored. No audit wiring on open/close — hover-open is noise, not signal (primitives ADR-003).