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 closebutton
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 { CloseButton } from '@oshon-ai/components';
export default function Example() {
return <CloseButton />;
}Variant matrix — light / solid / bordered
variant="light"
variant="solid"
variant="bordered"
<div style={{ ...stack, gap: '1.5rem' }}>
{VARIANTS.map((variant) => (
<section
key={variant}
style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}
>
<p style={caption}>variant="{variant}"</p>
<div
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'center',
padding: '12px',
borderRadius: '8px',
background: 'var(--oshon-color-surface, #fff)',
border: '1px solid var(--oshon-color-neutral-200, #e5e7e7)',
}}
>
<CloseButton variant={variant} ariaLabel={`Close (${variant})`} />
<CloseButton
variant={variant}
ariaLabel={`Close (${variant}, disabled)`}
disabled
/>
</div>
</section>
))}
</div>Size matrix — xs / s / m / l / mobile (28 px is the Modal default)
xs
s
m
l
mobile
<div style={{ ...stack, gap: '1.25rem' }}>
{SIZES.map((size) => (
<section
key={size}
style={{
display: 'flex',
flexDirection: 'row',
gap: '0.75rem',
alignItems: 'center',
}}
>
<p style={{ ...caption, minWidth: '60px' }}>{size}</p>
<CloseButton size={size} variant="light" ariaLabel={`Close (${size})`} />
<CloseButton size={size} variant="solid" ariaLabel={`Close (${size})`} />
<CloseButton size={size} variant="bordered" ariaLabel={`Close (${size})`} />
</section>
))}
</div>Modal-header use — title + CloseButton on the right
Confirm changes
{
function Demo() {
const [open, setOpen] = useState(true);
if (!open) {
return (
<div style={stack}>
<p style={caption}>Modal dismissed.</p>
<button
type="button"
onClick={() => setOpen(true)}
style={{
fontFamily: 'var(--oshon-font-family, system-ui)',
fontSize: '12px',
padding: '6px 10px',
borderRadius: '6px',
border: '1px solid var(--oshon-color-neutral-300, #dfe2e2)',
background: 'var(--oshon-color-surface, #fff)',
cursor: 'pointer',
}}
>
Reopen mock dialog
</button>
</div>
);
}
return (
<div
style={{
width: '420px',
border: '1px solid var(--oshon-color-neutral-200, #e5e7e7)',
borderRadius: '12px',
background: 'var(--oshon-color-surface, #fff)',
boxShadow: '0 12px 32px rgba(9, 36, 37, 0.12)',
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--oshon-color-neutral-200, #e5e7e7)',
}}
>
<h3 style={{ ...heading, fontSize: '15px' }}>Confirm changes</h3>
<CloseButton
ariaLabel="Close dialog"
onClick={() => setOpen(false)}
/>
</header>
<div
style={{
padding: '14px 16px',
fontFamily: 'var(--oshon-font-family, system-ui)',
fontSize: '12px',
color: 'var(--oshon-color-neutral-700, #445354)',
}}
>
The CloseButton in the header dismisses this mock dialog.
</div>
</div>
);
}
return <Demo />;
}Solid variant — on a busy / image-overlay surface
On busy surfaces (image thumbnails, video previews) the solid variant ensures the dismiss control reads clearly without depending on a transparent background.
<div style={stack}>
<h3 style={heading}>
On busy surfaces (image thumbnails, video previews) the{' '}
<code>solid</code> variant ensures the dismiss control reads
clearly without depending on a transparent background.
</h3>
<div
style={{
position: 'relative',
width: '320px',
height: '180px',
borderRadius: '12px',
background:
'linear-gradient(135deg, #4ca9b8 0%, #1c5f6c 60%, #0d2c34 100%)',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
}}
>
<CloseButton variant="solid" ariaLabel="Close preview" />
</div>
<p
style={{
position: 'absolute',
bottom: '12px',
left: '14px',
margin: 0,
color: 'var(--oshon-color-surface, #fff)',
fontFamily: 'var(--oshon-font-family, system-ui)',
fontSize: '12px',
}}
>
Mock preview surface
</p>
</div>
</div>Custom icon — repurposed for a destructive
Pass any ReactNode as icon — the supplied glyph inherits currentColor so the variant's text token retints it.
<div style={stack}>
<h3 style={heading}>
Pass any ReactNode as <code>icon</code> — the supplied glyph
inherits <code>currentColor</code> so the variant's text
token retints it.
</h3>
<CloseButton
ariaLabel="Delete draft"
size="l"
variant="bordered"
icon={
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9a1 1 0 001 1h3a1 1 0 001-1L11 4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
/>
</div>Click counter — onClick wiring through to the consumer
onClick fires every activation (mouse + keyboard via Space / Enter on the focused button).
Click count: 0
{
function Demo() {
const [count, setCount] = useState(0);
return (
<div style={stack}>
<h3 style={heading}>
<code>onClick</code> fires every activation (mouse + keyboard
via Space / Enter on the focused button).
</h3>
<p style={caption}>
Click count: <code>{count}</code>
</p>
<CloseButton
onClick={() => setCount((n) => n + 1)}
ariaLabel="Increment counter"
/>
</div>
);
}
return <Demo />;
}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.
<CloseButton 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.
iconOptional override for the X glyph. Default — Oshon's stroke-1.6 16×16 close icon (matches BannerCloseIcon + Modal's inline CloseIcon verbatim). The supplied node inherits `currentColor` so the variant's text token retints it without per-icon plumbing.
Keyboard
Tab focuses the button (it is a native `<button type="button">`). Space and Enter activate it (browser default). The visible glyph is `aria-hidden`; the accessible name comes from the `ariaLabel` prop (default `"Close"`). When supplied, override with a more specific label such as `"Close dialog"` or `"Dismiss banner"` so screen-reader users hear what is being dismissed. The component supports the standard `disabled` attribute — disabled buttons are removed from the tab order and the click/keyboard activation is suppressed by the browser.
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-30
Do / Don't
✓ Do
<CloseButton onClick={() => setOpen(false)} /><CloseButton variant="solid" ariaLabel="Close preview" onClick={() => setPreview(null)} /><CloseButton variant="bordered" size="s" ariaLabel="Dismiss banner" />
<CloseButton icon={<TrashIcon />} ariaLabel="Delete draft" />✗ Don't
<CloseButton onClick={dismissBanner} />The default `ariaLabel="Close"` is fine for generic Modal / Dialog dismiss but ambiguous when the same screen has multiple closable surfaces (e.g. a Banner above an open Modal). Override with the specific target ("Dismiss banner", "Close preview") so screen-reader users know exactly what is being dismissed.
<div onClick={onClose}><CloseButton /></div>The wrapper steals the click target without inheriting the keyboard / focus / aria-label semantics of the inner button. Style the CloseButton directly (className / style) or wrap in a non-interactive layout container (a `<div>` without onClick) instead.
<CloseButton onClick={submitForm} ariaLabel="Submit" />CloseButton ships as an icon-only control sized for a 20–40 px hit target — too small for a primary action and visually wrong (the X glyph reads as "dismiss", not "submit"). Reach for `<ButtonIconText>` or `<ButtonHug>` with the appropriate verb instead.
<header><CloseButton /><CloseButton variant="solid" /></header>
A surface should have at most one canonical dismiss affordance. Two close buttons confuse the user about which one returns to the previous state vs. cancels the action entirely. Use Modal / Dialog's built-in close + a single secondary CTA in the footer instead.
Design rationale
The X-glyph dismiss button shows up across Modal, Banner, Snackbar, Toaster, Panel, and Popover today — each one re-implements the same 24×24 hit target + stroke-1.6 X icon + hover token. Standardizing the recipe as a primitive cuts the duplication, lets consumers drop the same control into custom shells (image overlays, command palettes, drawer headers), and gives us one place to evolve the visual language. The three variants cover the bulk of the design landscape — `light` for opinionated surfaces (Modal headers), `solid` for busy surfaces (image overlays), `bordered` for flat surfaces (toolbars). Five-size axis matches the rest of the system; native `<button type="button">` keeps the keyboard / focus / form-submission semantics free.