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';