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

Close Button

Standalone × button — dismisses modal/panel/banner.

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 closebutton

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 { CloseButton } from '@oshon-ai/components';

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

Variant matrix — light / solid / bordered

variant="light"

variant="solid"

variant="bordered"

tsx
<div style={{ ...stack, gap: '1.5rem' }}>
      {VARIANTS.map((variant) => (
        <section
          key={variant}
          style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}
        >
          <p style={caption}>variant=&quot;{variant}&quot;</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

tsx
<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>

Confirm changes

The CloseButton in the header dismisses this mock dialog.
tsx
{
    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.

Mock preview surface

tsx
<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.

tsx
<div style={stack}>
      <h3 style={heading}>
        Pass any ReactNode as <code>icon</code> — the supplied glyph
        inherits <code>currentColor</code> so the variant&apos;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

tsx
{
    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.

tsx
<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.

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.

icon

Optional 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

Default — Modal / Banner header dismiss
<CloseButton onClick={() => setOpen(false)} />
Solid — image-overlay dismiss with descriptive label
<CloseButton variant="solid" ariaLabel="Close preview" onClick={() => setPreview(null)} />
Bordered — flat-toolbar dismiss
<CloseButton variant="bordered" size="s" ariaLabel="Dismiss banner" />
Custom icon — re-purposed for a destructive action
<CloseButton icon={<TrashIcon />} ariaLabel="Delete draft" />

✗ Don't

Skipping `ariaLabel` for non-default dismiss targets
<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.

Wrapping the button in a `<div onClick>` for layout
<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.

Using CloseButton as the primary form-submit action
<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.

Stacking two CloseButtons on the same surface
<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.