Skip to content

Stage Banner

A full-width announcement strip with a status dot, optional action button and dismiss control.

A full-width announcement strip with a status dot, optional action button and dismiss control. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-stage-banner
import {
  StageBanner,
  StageBannerAction,
  StageBannerClose,
  StageBannerDot,
} from "@/registry/bases/newt/ui/stage-banner"

export default function StageBannerDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <StageBanner>
        <StageBannerDot />
        Announcement text goes here.
        <StageBannerAction>Action</StageBannerAction>
      </StageBanner>

      <StageBanner variant="neutral">
        <StageBannerDot status="online" />
        Status message goes here.
        <StageBannerClose className="text-newt-text-muted hover:text-newt-text-primary" />
      </StageBanner>
    </div>
  )
}
<script setup lang="ts">
import {
  StageBanner,
  StageBannerAction,
  StageBannerClose,
  StageBannerDot,
} from "~~/registry/bases/newt/ui/stage-banner"
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <StageBanner>
      <StageBannerDot />
      Announcement text goes here.
      <StageBannerAction>Action</StageBannerAction>
    </StageBanner>

    <StageBanner variant="neutral">
      <StageBannerDot status="online" />
      Status message goes here.
      <StageBannerClose
        class="text-newt-text-muted hover:text-newt-text-primary"
      />
    </StageBanner>
  </div>
</template>

Installation

pnpm dlx newtui@latest add stage-banner
npx newtui@latest add stage-banner
yarn dlx newtui@latest add stage-banner
bunx newtui@latest add stage-banner

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Copy the source into your project

"use client"

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const stageBannerVariants = cva(
  "group flex items-center gap-3 px-4 py-2.5 text-[13px] font-medium",
  {
    variants: {
      variant: {
        brand: "bg-newt-brand text-white",
        neutral:
          "border border-newt-border bg-newt-bg-elevated text-newt-text-primary",
      },
    },
    defaultVariants: {
      variant: "brand",
    },
  }
)

export interface StageBannerProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof stageBannerVariants> {
  /** Controlled visibility; when omitted the banner manages its own state. */
  open?: boolean
  /** Fired when the banner is dismissed via StageBannerClose. */
  onOpenChange?: (open: boolean) => void
}

type StageBannerContextValue = { close: () => void }
const StageBannerContext = React.createContext<StageBannerContextValue>({
  close: () => {},
})

const StageBanner = React.forwardRef<HTMLDivElement, StageBannerProps>(
  ({ className, variant, open, onOpenChange, children, ...props }, ref) => {
    const [internalOpen, setInternalOpen] = React.useState(true)
    const isControlled = open !== undefined
    const isOpen = isControlled ? open : internalOpen

    const close = React.useCallback(() => {
      if (!isControlled) setInternalOpen(false)
      onOpenChange?.(false)
    }, [isControlled, onOpenChange])

    if (!isOpen) return null

    return (
      <StageBannerContext.Provider value={{ close }}>
        <div
          ref={ref}
          data-variant={variant ?? "brand"}
          className={cn(stageBannerVariants({ variant }), className)}
          {...props}
        >
          {children}
        </div>
      </StageBannerContext.Provider>
    )
  }
)
StageBanner.displayName = "StageBanner"

const stageBannerDotVariants = cva("h-2 w-2 shrink-0 rounded-full", {
  variants: {
    status: {
      white: "bg-white",
      online: "bg-newt-online",
      idle: "bg-newt-idle",
      dnd: "bg-newt-dnd",
      offline: "bg-newt-offline",
    },
  },
  defaultVariants: {
    status: "white",
  },
})

export interface StageBannerDotProps
  extends
    React.HTMLAttributes<HTMLSpanElement>,
    VariantProps<typeof stageBannerDotVariants> {}

const StageBannerDot = React.forwardRef<HTMLSpanElement, StageBannerDotProps>(
  ({ className, status, ...props }, ref) => (
    <span
      ref={ref}
      aria-hidden="true"
      className={cn(stageBannerDotVariants({ status }), className)}
      {...props}
    />
  )
)
StageBannerDot.displayName = "StageBannerDot"

const StageBannerAction = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, type = "button", ...props }, ref) => (
  <button
    ref={ref}
    type={type}
    className={cn(
      "ms-auto shrink-0 cursor-pointer rounded-sm border-0 bg-black/20 px-2.5 py-1 text-xs font-semibold text-white transition-colors duration-fast ease-newt hover:bg-black/[0.32]",
      className
    )}
    {...props}
  />
))
StageBannerAction.displayName = "StageBannerAction"

const StageBannerClose = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, onClick, children, type = "button", ...props }, ref) => {
  const { close } = React.useContext(StageBannerContext)
  return (
    <button
      ref={ref}
      type={type}
      aria-label="Dismiss"
      className={cn(
        "ms-auto shrink-0 cursor-pointer border-0 bg-transparent p-0 text-base leading-none text-white/80 transition-colors duration-fast ease-newt hover:text-white",
        "group-data-[variant=neutral]:text-newt-text-muted group-data-[variant=neutral]:hover:text-newt-text-primary",
        className
      )}
      onClick={(e) => {
        onClick?.(e)
        if (!e.defaultPrevented) close()
      }}
      {...props}
    >
      {children ?? "×"}
    </button>
  )
})
StageBannerClose.displayName = "StageBannerClose"

export {
  StageBanner,
  StageBannerDot,
  StageBannerAction,
  StageBannerClose,
  stageBannerVariants,
  stageBannerDotVariants,
}
<script setup lang="ts">
import { computed, provide, ref, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import {
  STAGE_BANNER_INJECTION_KEY,
  stageBannerVariants,
  type StageBannerVariants,
} from "."

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    variant?: StageBannerVariants["variant"]
    open?: boolean
  }>(),
  { variant: "brand", open: undefined }
)

const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()

const internalOpen = ref(true)
const isControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
  isControlled.value ? props.open : internalOpen.value
)

function close() {
  if (!isControlled.value) internalOpen.value = false
  emit("update:open", false)
}

provide(STAGE_BANNER_INJECTION_KEY, { close })
</script>

<template>
  <div
    v-if="isOpen"
    :data-variant="props.variant"
    :class="cn(stageBannerVariants({ variant: props.variant }), props.class)"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { stageBannerDotVariants, type StageBannerDotVariants } from "."

const props = defineProps<{
  class?: HTMLAttributes["class"]
  status?: StageBannerDotVariants["status"]
}>()
</script>

<template>
  <span
    aria-hidden="true"
    :class="cn(stageBannerDotVariants({ status: props.status }), props.class)"
  />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <button
    type="button"
    :class="
      cn(
        'ms-auto shrink-0 cursor-pointer rounded-sm border-0 bg-black/20 px-2.5 py-1 text-xs font-semibold text-white transition-colors duration-fast ease-newt hover:bg-black/[0.32]',
        props.class
      )
    "
  >
    <slot />
  </button>
</template>
<script setup lang="ts">
import { inject, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { STAGE_BANNER_INJECTION_KEY } from "."

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const ctx = inject(STAGE_BANNER_INJECTION_KEY, null)
</script>

<template>
  <button
    type="button"
    aria-label="Dismiss"
    :class="
      cn(
        'ms-auto shrink-0 cursor-pointer border-0 bg-transparent p-0 text-base leading-none text-white/80 transition-colors duration-fast ease-newt hover:text-white group-data-[variant=neutral]:text-newt-text-muted group-data-[variant=neutral]:hover:text-newt-text-primary',
        props.class
      )
    "
    @click="ctx?.close()"
  >
    <slot>&times;</slot>
  </button>
</template>
import { cva, type VariantProps } from "class-variance-authority"
import type { InjectionKey } from "vue"

export { default as StageBanner } from "./StageBanner.vue"
export { default as StageBannerDot } from "./StageBannerDot.vue"
export { default as StageBannerAction } from "./StageBannerAction.vue"
export { default as StageBannerClose } from "./StageBannerClose.vue"

export const stageBannerVariants = cva(
  "group flex items-center gap-3 px-4 py-2.5 text-[13px] font-medium",
  {
    variants: {
      variant: {
        brand: "bg-newt-brand text-white",
        neutral:
          "border border-newt-border bg-newt-bg-elevated text-newt-text-primary",
      },
    },
    defaultVariants: {
      variant: "brand",
    },
  }
)

export const stageBannerDotVariants = cva("h-2 w-2 shrink-0 rounded-full", {
  variants: {
    status: {
      white: "bg-white",
      online: "bg-newt-online",
      idle: "bg-newt-idle",
      dnd: "bg-newt-dnd",
      offline: "bg-newt-offline",
    },
  },
  defaultVariants: {
    status: "white",
  },
})

export type StageBannerVariants = VariantProps<typeof stageBannerVariants>
export type StageBannerDotVariants = VariantProps<typeof stageBannerDotVariants>

export interface StageBannerContext {
  close: () => void
}

export const STAGE_BANNER_INJECTION_KEY: InjectionKey<StageBannerContext> =
  Symbol("newt-stage-banner")

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { StageBanner } from "@/components/ui/stage-banner"

export function Example() {
  return <StageBanner />
}
<script setup lang="ts">
import { StageBanner, StageBannerDot, StageBannerAction, StageBannerClose } from "@/components/ui/stage-banner"
</script>

<template>
  <StageBanner />
</template>

API reference

StageBanner

PropType
open?boolean | undefined

Controlled visibility; when omitted the banner manages its own state.

Typeboolean | undefined
onOpenChange?(open: boolean) => void | undefined

Fired when the banner is dismissed via StageBannerClose.

Type(open: boolean) => void | undefined

The registry exports StageBanner, StageBannerDot, StageBannerAction, StageBannerClose. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Stage Banner reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-stage-banner.