Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add tooltip
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'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 }
Position = Bottom · Alignment ∈ { Left, Center, Right }
Position = Side · Alignment ∈ { Left, Right } (Side+Center is not authored in Figma)
<div style={{ ...col, gap: '1.5rem' }}>
<h3 style={heading}>
Position = Top · Alignment ∈ { Left, Center, Right }
</h3>
<div style={row}>
{TOP_CELLS.map((c) => (
<MatrixCell key={c.label} {...c} />
))}
</div>
<h3 style={heading}>
Position = Bottom · Alignment ∈ { Left, Center, Right }
</h3>
<div style={row}>
{BOTTOM_CELLS.map((c) => (
<MatrixCell key={c.label} {...c} />
))}
</div>
<h3 style={heading}>
Position = Side · Alignment ∈ { Left, Right } (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.
<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="{size}"</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.
This is a sentence that might need more explanation.
This is a sentence that might need more explanation.
<div style={{ ...col, gap: '1rem' }}>
<h3 style={heading}>
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.
</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.
<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.
<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'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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
ProviderConfigures default delay + skip-delay behavior for every descendant Tooltip. Mount once near the app root.
TriggerElement that opens the tooltip on hover/focus. Pass `asChild` (Radix pass-through) to compose on top of another control — typical pattern.
ContentThe 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.
ArrowCrisp 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
<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><Tooltip.Content side="top" align="start"> This is a sentence that might need more explanation. <Tooltip.Arrow /> </Tooltip.Content>
<Tooltip.Content side="right" align="center"> Tooltip text <Tooltip.Arrow /> </Tooltip.Content>
<Tooltip.Root permissions={{ can: () => canViewHelp }} resource="field:ssn-help">
…
</Tooltip.Root>✗ Don't
<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.Root><Tooltip.Trigger>?</Tooltip.Trigger></Tooltip.Root>
Radix requires Provider to share delay state across sibling tooltips. Mount one Provider at the app root.
<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).