Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Banner

An inline, persistent status message with variants and actions.

An inline, persistent status message that sits in normal layout flow — never fixed. Variants (info / warning / error / success) tint the leading icon from semantic tokens while the surface stays neutral. Android renders a full-width MD3 banner with a bottom hairline; iOS renders a grouped-inset rounded card. Both share the same markup — the cascade decides the frame.

Installation

npx touchcn add banner

Usage

<tcn-banner variant="warning" dismissible (dismissed)="onDismiss()">
  Your subscription expires in 3 days.
  <div banner-actions>
    <button tcnButton size="sm">Renew</button>
  </div>
</tcn-banner>
import { TcnBanner } from '@/components/ui/banner';

<TcnBanner
  variant="warning"
  dismissible
  onDismiss={onDismiss}
  actions={<TcnButton size="sm">Renew</TcnButton>}
>
  Your subscription expires in 3 days.
</TcnBanner>
<script setup lang="ts">
import { TcnBanner } from '@/components/ui/banner';
import { TcnButton } from '@/components/ui/button';
</script>

<template>
  <TcnBanner variant="warning" dismissible @dismiss="onDismiss">
    Your subscription expires in 3 days.
    <template #actions>
      <TcnButton size="sm">Renew</TcnButton>
    </template>
  </TcnBanner>
</template>

The default content is the message; action buttons project into [banner-actions] (Angular) or the actions prop (React). dismissible adds a trailing close button. Error and warning banners carry role="alert"; info and success use role="status".

Props

Prop Type Default Description
variant 'info' | 'warning' | 'error' | 'success' 'info' Tints the leading icon and ARIA role.
dismissible boolean false Show a trailing close button.
dismissed / onDismiss event Fired when the close button is pressed.
Source · Angular
export * from './tcn-banner';
import { booleanAttribute, Component, computed, ElementRef, inject, input, output } from '@angular/core';
import { cn } from '@touchcn/core';

export type TcnBannerVariant = 'info' | 'warning' | 'error' | 'success';

/**
 * Banner — an inline, persistent status message that sits in normal layout
 * flow (never fixed). Variants (`info` / `warning` / `error` / `success`) tint
 * the leading icon from semantic tokens. MD3 renders a full-width surface with
 * a bottom hairline; iOS renders a grouped-inset rounded card. Both share this
 * markup; the cascade decides the frame.
 *
 * Slots: the default content is the message; `[banner-actions]` projects
 * optional action buttons. `dismissible` adds a close button emitting
 * `dismissed`.
 */
@Component({
  selector: 'tcn-banner',
  template: `
    <span class="tcn-banner-icon" aria-hidden="true">
      @switch (variant()) {
        @case ('success') {
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
            <path d="M8.5 12l2.5 2.5 4.5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        }
        @case ('warning') {
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <path d="M12 3.5 21 19H3L12 3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
            <path d="M12 10v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
            <circle cx="12" cy="16.5" r="1" fill="currentColor" />
          </svg>
        }
        @case ('error') {
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
            <path d="M12 7.5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
            <circle cx="12" cy="16" r="1" fill="currentColor" />
          </svg>
        }
        @default {
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
            <path d="M12 11.5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
            <circle cx="12" cy="8" r="1" fill="currentColor" />
          </svg>
        }
      }
    </span>
    <div class="tcn-banner-content">
      <div class="tcn-banner-message"><ng-content /></div>
      <div class="tcn-banner-actions empty:hidden"><ng-content select="[banner-actions]" /></div>
    </div>
    @if (dismissible()) {
      <button type="button" class="tcn-banner-dismiss" aria-label="Dismiss" (click)="dismissed.emit()">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        </svg>
      </button>
    }
  `,
  host: {
    '[class]': 'classes()',
    '[attr.data-variant]': 'variant()',
    '[attr.role]': 'variant() === "error" || variant() === "warning" ? "alert" : "status"',
  },
})
export class TcnBanner {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly variant = input<TcnBannerVariant>('info');
  readonly dismissible = input(false, { transform: booleanAttribute });
  readonly dismissed = output<void>();

  protected readonly classes = computed(() => cn('tcn-banner', this.authored));
}
Source · React
export * from './tcn-banner';
import type { ReactNode } from 'react';
import { cn } from '@touchcn/core';

export type TcnBannerVariant = 'info' | 'warning' | 'error' | 'success';

const icons: Record<TcnBannerVariant, ReactNode> = {
  success: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
      <path d="M8.5 12l2.5 2.5 4.5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  ),
  warning: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
      <path d="M12 3.5 21 19H3L12 3.5Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
      <path d="M12 10v4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <circle cx="12" cy="16.5" r="1" fill="currentColor" />
    </svg>
  ),
  error: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
      <path d="M12 7.5v5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <circle cx="12" cy="16" r="1" fill="currentColor" />
    </svg>
  ),
  info: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
      <path d="M12 11.5v5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <circle cx="12" cy="8" r="1" fill="currentColor" />
    </svg>
  ),
};

export interface TcnBannerProps {
  variant?: TcnBannerVariant;
  /** Show a trailing close button that calls `onDismiss`. */
  dismissible?: boolean;
  onDismiss?: () => void;
  /** Optional action buttons rendered under the message. */
  actions?: ReactNode;
  children?: ReactNode;
  className?: string;
}

/**
 * Banner — an inline, persistent status message that sits in normal layout
 * flow (never fixed). Variants (`info` / `warning` / `error` / `success`) tint
 * the leading icon from semantic tokens. MD3 renders a full-width surface with
 * a bottom hairline; iOS renders a grouped-inset rounded card. Both share this
 * markup; the cascade decides the frame.
 *
 * The default children are the message; `actions` renders optional action
 * buttons. `dismissible` adds a close button calling `onDismiss`.
 */
export function TcnBanner({
  variant = 'info',
  dismissible = false,
  onDismiss,
  actions,
  children,
  className,
}: TcnBannerProps) {
  return (
    <div
      className={cn('tcn-banner', className)}
      data-variant={variant}
      role={variant === 'error' || variant === 'warning' ? 'alert' : 'status'}
    >
      <span className="tcn-banner-icon" aria-hidden="true">
        {icons[variant]}
      </span>
      <div className="tcn-banner-content">
        <div className="tcn-banner-message">{children}</div>
        {actions && <div className="tcn-banner-actions">{actions}</div>}
      </div>
      {dismissible && (
        <button type="button" className="tcn-banner-dismiss" aria-label="Dismiss" onClick={onDismiss}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
          </svg>
        </button>
      )}
    </div>
  );
}
Source · Vue
<script lang="ts">
export type TcnBannerVariant = 'info' | 'warning' | 'error' | 'success';
</script>

<script setup lang="ts">
import { computed } from 'vue';

/**
 * Banner — an inline, persistent status message that sits in normal layout flow
 * (never fixed). Variants (`info` / `warning` / `error` / `success`) tint the
 * leading icon from semantic tokens. MD3 renders a full-width surface with a
 * bottom hairline; iOS renders a grouped-inset rounded card. Both share this
 * markup; the cascade decides the frame.
 *
 * The default slot is the message; the `actions` slot renders optional action
 * buttons. `dismissible` adds a close button emitting `dismiss`.
 */
const props = withDefaults(
  defineProps<{
    variant?: TcnBannerVariant;
    /** Show a trailing close button that emits `dismiss`. */
    dismissible?: boolean;
  }>(),
  { variant: 'info', dismissible: false },
);

defineEmits<{ dismiss: [] }>();

const role = computed(() =>
  props.variant === 'error' || props.variant === 'warning' ? 'alert' : 'status',
);
</script>

<template>
  <div class="tcn-banner" :data-variant="variant" :role="role">
    <span class="tcn-banner-icon" aria-hidden="true">
      <svg v-if="variant === 'success'" width="22" height="22" viewBox="0 0 24 24" fill="none">
        <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
        <path
          d="M8.5 12l2.5 2.5 4.5-5"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
      <svg v-else-if="variant === 'warning'" width="22" height="22" viewBox="0 0 24 24" fill="none">
        <path d="M12 3.5 21 19H3L12 3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
        <path d="M12 10v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        <circle cx="12" cy="16.5" r="1" fill="currentColor" />
      </svg>
      <svg v-else-if="variant === 'error'" width="22" height="22" viewBox="0 0 24 24" fill="none">
        <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
        <path d="M12 7.5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        <circle cx="12" cy="16" r="1" fill="currentColor" />
      </svg>
      <svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none">
        <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
        <path d="M12 11.5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        <circle cx="12" cy="8" r="1" fill="currentColor" />
      </svg>
    </span>
    <div class="tcn-banner-content">
      <div class="tcn-banner-message"><slot /></div>
      <div v-if="$slots.actions" class="tcn-banner-actions"><slot name="actions" /></div>
    </div>
    <button
      v-if="dismissible"
      type="button"
      class="tcn-banner-dismiss"
      aria-label="Dismiss"
      @click="$emit('dismiss')"
    >
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
      </svg>
    </button>
  </div>
</template>
export { default as TcnBanner } from './TcnBanner.vue';
export type { TcnBannerVariant } from './TcnBanner.vue';

Last updated on July 24, 2026

Was this page helpful?