Lunia DS

Base components

Close button

An X button that closes dialogs, banners, notifications and side panels, with a light and a dark background style, which eases its fill on hover and gives a small press.

Loading preview

A light card with an sm close button in its corner, a brand banner with a dark background md button, and a row of the four sizes. Replay hovers and presses the banner button.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add close-button

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { CloseButton } from './close-button';

const sizes = ['xs', 'sm', 'md', 'lg'] as const;
// Replay shows the banner button's hover fill, then its press, then lets it go.
const STEPS = [
  { at: 0, state: 'hover' },
  { at: 500, state: 'press' },
  { at: 700, state: 'hover' },
  { at: 1300, state: 'rest' },
] as const;
type DemoState = (typeof STEPS)[number]['state'];

const demoState: Record<DemoState, string> = {
  rest: '',
  hover: 'bg-colors-alpha-white-20',
  press: 'bg-colors-alpha-white-20 scale-(--lunia-scale-press) motion-reduce:scale-100',
};

export default function CloseButtonDemo({ replay = 0 }: { replay?: number }) {
  const [state, setState] = React.useState<DemoState>('rest');
  const [banner, setBanner] = React.useState(true);

  React.useEffect(() => {
    if (replay === 0) return;
    setBanner(true);
    const timers = STEPS.map((step) => window.setTimeout(() => setState(step.state), step.at));
    return () => {
      for (const t of timers) window.clearTimeout(t);
    };
  }, [replay]);

  return (
    <div className="flex w-full max-w-md flex-col gap-6">
      <div className="relative rounded-xl border border-secondary bg-primary p-5 pe-14 shadow-sm">
        <p className="text-sm font-semibold text-primary">Invite your team</p>
        <p className="mt-1 text-sm text-tertiary">
          Add people to this workspace to work on projects together.
        </p>
        <CloseButton label="Close invite card" className="absolute end-3 top-3" />
      </div>
      {banner ? (
        <div className="flex items-center gap-3 rounded-xl bg-brand-section py-2 ps-4 pe-2">
          <p className="flex-1 text-sm font-medium text-white">Version 2.4 is available.</p>
          <CloseButton
            size="md"
            darkBackground
            label="Close banner"
            className={demoState[state]}
            onClick={() => setBanner(false)}
          />
        </div>
      ) : (
        <button
          type="button"
          className="self-start text-sm font-semibold text-brand-secondary underline-offset-4 hover:underline"
          onClick={() => setBanner(true)}
        >
          Show the banner again
        </button>
      )}
      <div className="flex items-center justify-center gap-3">
        {sizes.map((size) => (
          <CloseButton key={size} size={size} label={`Close, size ${size}`} />
        ))}
      </div>
    </div>
  );
}

API

Parts: root (button), X icon.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg""sm"28, 36, 40 or 44px, with a 16, 20, 20 or 24px icon.
darkBackgroundbooleanfalseWhite icon and a translucent white hover fill, for dark or brand surfaces.
labelstring"Close"Accessible name. Name the thing when there are several on a page, for example "Close notification".
disabledbooleanfalseTurns the button off (50% opacity).
EventDescription
clickFires on activation. The parent closes the surface.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
CLB1hoverroot, iconbackground-color, icon colortransparent, fg-tertiary (fg-white on dark) → bg-primary_hover, fg-tertiary_hover (white-20 fill, fg-white on dark)duration-sm (200)ease-outSwaps at once
CLB2press (pointer down or Space held)rootscale1 → scale-pressduration-sm (200)ease-outStays still
CLB3releaserootscalescale-press → 1duration-sm (200)ease-outStays still
CLB4keyboard focusrootbox-shadownone → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once

Accessibility

  • Native button with aria-label set to label (default "Close"); the X icon is aria-hidden.
  • The surface that owns the button also closes on Escape where its pattern allows it (dialogs, popovers); the button is the pointer and touch path, not the only path.
  • When the button sits inside a dialog primitive, use the primitive's close part (Base UI Dialog.Close, Reka UI DialogClose) as the root so focus returns to the opener.
  • Every size is 28px or larger, so the target meets the 24 x 24px minimum (WCAG 2.5.8).
  • Reduced motion: colors swap at once and the button does not scale.