Announcement

Dismissible announcement card used in the sidebar footer.

Card

A dismissible sidebar announcement.

BoardCN 1.0 is here

85 components, 18 charts, and 6 templates — all free.

function AnnouncementDemo() {
  return (
    <div className="w-full max-w-xs">
      <Announcement
        title="BoardCN 1.0 is here"
        description="85 components, 18 charts, and 6 templates — all free."
        actionLabel="Read the changelog"
        dismissible
      />
    </div>
  );
}
function AnnouncementDemo() {
  return (
    <div className="w-full max-w-xs">
      <Announcement
        title="BoardCN 1.0 is here"
        description="85 components, 18 charts, and 6 templates — all free."
        actionLabel="Read the changelog"
        dismissible
      />
    </div>
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/announcement.json
npx shadcn@latest add https://boardcn.dev/r/announcement.json

npm packages

  • @remixicon/react
  • motion

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/base/announcement/announcement.tsx
"use client";

import type { ComponentType, HTMLAttributes, ReactNode, Ref } from "react";
import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { RiShieldStarFill } from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { CloseButton } from "@/components/base/buttons/close-button";
import { cx, sortCx } from "@/utils/cx";

type IconComponent = ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export interface AnnouncementProps
  extends Omit<
    HTMLAttributes<HTMLDivElement>,
    "title" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  title: ReactNode;
  description?: ReactNode;
  
  icon?: IconComponent;
  /** CTA label. When set, a full-width secondary button is rendered. */
  actionLabel?: ReactNode;
  onAction?: () => void;
  /** Show a dismiss button in the top-right corner. */
  dismissible?: boolean;
  /** Called once the dismiss exit animation finishes. */
  onClose?: () => void;
  /** Accessible name for the dismiss button. */
  closeLabel?: string;
  /**
   * Seconds to wait before playing the blur/fade/scale-up entrance. Omit to
   * skip the entrance entirely (card just appears).
   */
  introDelay?: number;
  ref?: Ref<HTMLDivElement>;
}

const styles = sortCx({
  card: [
    "relative flex w-full flex-col items-start gap-3 p-3",
    "rounded-xl border border-border-button-default bg-background-primary-default",
  ].join(" "),
  content: "flex w-full flex-col items-start gap-1",
  icon: "size-5 shrink-0 text-foreground-icon-secondary",
  close: "absolute right-3 top-3",
  text: "flex w-full flex-col items-start gap-0.5",
  title: "w-full text-body-medium text-text-primary",
  description: "w-full text-body-2-medium text-text-secondary",
});

export function Announcement({
  title,
  description,
  icon: Icon = RiShieldStarFill,
  actionLabel,
  onAction,
  dismissible = false,
  onClose,
  closeLabel = "Dismiss",
  introDelay,
  className,
  ref,
  ...props
}: AnnouncementProps) {
  const [dismissed, setDismissed] = useState(false);
  const hasIntro = introDelay !== undefined;

  return (
    <AnimatePresence onExitComplete={onClose}>
      {!dismissed && (
        <motion.div
          ref={ref}
          initial={hasIntro ? { opacity: 0, y: 15, scale: 0.85, filter: "blur(6px)" } : false}
          animate={
            hasIntro
              ? {
                  opacity: 1,
                  y: 0,
                  scale: 1,
                  filter: "blur(0px)",
                  transition: { duration: 0.25, ease: "easeOut", delay: introDelay },
                }
              : undefined
          }
          exit={{
            opacity: 0,
            scale: 0.85,
            filter: "blur(6px)",
            transition: { duration: 0.25, ease: "easeInOut" },
          }}
          className={cx(styles.card, className)}
          {...props}
        >
          <div className={styles.content}>
            <Icon className={styles.icon} aria-hidden />

            {dismissible ? (
              <CloseButton
                size="xs"
                aria-label={closeLabel}
                onClick={() => setDismissed(true)}
                className={styles.close}
              />
            ) : null}

            <div className={styles.text}>
              <p className={styles.title}>{title}</p>
              {description ? <p className={styles.description}>{description}</p> : null}
            </div>
          </div>

          {actionLabel ? (
            <Button
              variant="secondary"
              size="small"
              onClick={onAction}
              className="w-full"
            >
              {actionLabel}
            </Button>
          ) : null}
        </motion.div>
      )}
    </AnimatePresence>
  );
}
"use client";

import type { ComponentType, HTMLAttributes, ReactNode, Ref } from "react";
import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { RiShieldStarFill } from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { CloseButton } from "@/components/base/buttons/close-button";
import { cx, sortCx } from "@/utils/cx";

type IconComponent = ComponentType<{
  className?: string;
  "aria-hidden"?: boolean | "true" | "false";
}>;

export interface AnnouncementProps
  extends Omit<
    HTMLAttributes<HTMLDivElement>,
    "title" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  title: ReactNode;
  description?: ReactNode;
  
  icon?: IconComponent;
  /** CTA label. When set, a full-width secondary button is rendered. */
  actionLabel?: ReactNode;
  onAction?: () => void;
  /** Show a dismiss button in the top-right corner. */
  dismissible?: boolean;
  /** Called once the dismiss exit animation finishes. */
  onClose?: () => void;
  /** Accessible name for the dismiss button. */
  closeLabel?: string;
  /**
   * Seconds to wait before playing the blur/fade/scale-up entrance. Omit to
   * skip the entrance entirely (card just appears).
   */
  introDelay?: number;
  ref?: Ref<HTMLDivElement>;
}

const styles = sortCx({
  card: [
    "relative flex w-full flex-col items-start gap-3 p-3",
    "rounded-xl border border-border-button-default bg-background-primary-default",
  ].join(" "),
  content: "flex w-full flex-col items-start gap-1",
  icon: "size-5 shrink-0 text-foreground-icon-secondary",
  close: "absolute right-3 top-3",
  text: "flex w-full flex-col items-start gap-0.5",
  title: "w-full text-body-medium text-text-primary",
  description: "w-full text-body-2-medium text-text-secondary",
});

export function Announcement({
  title,
  description,
  icon: Icon = RiShieldStarFill,
  actionLabel,
  onAction,
  dismissible = false,
  onClose,
  closeLabel = "Dismiss",
  introDelay,
  className,
  ref,
  ...props
}: AnnouncementProps) {
  const [dismissed, setDismissed] = useState(false);
  const hasIntro = introDelay !== undefined;

  return (
    <AnimatePresence onExitComplete={onClose}>
      {!dismissed && (
        <motion.div
          ref={ref}
          initial={hasIntro ? { opacity: 0, y: 15, scale: 0.85, filter: "blur(6px)" } : false}
          animate={
            hasIntro
              ? {
                  opacity: 1,
                  y: 0,
                  scale: 1,
                  filter: "blur(0px)",
                  transition: { duration: 0.25, ease: "easeOut", delay: introDelay },
                }
              : undefined
          }
          exit={{
            opacity: 0,
            scale: 0.85,
            filter: "blur(6px)",
            transition: { duration: 0.25, ease: "easeInOut" },
          }}
          className={cx(styles.card, className)}
          {...props}
        >
          <div className={styles.content}>
            <Icon className={styles.icon} aria-hidden />

            {dismissible ? (
              <CloseButton
                size="xs"
                aria-label={closeLabel}
                onClick={() => setDismissed(true)}
                className={styles.close}
              />
            ) : null}

            <div className={styles.text}>
              <p className={styles.title}>{title}</p>
              {description ? <p className={styles.description}>{description}</p> : null}
            </div>
          </div>

          {actionLabel ? (
            <Button
              variant="secondary"
              size="small"
              onClick={onAction}
              className="w-full"
            >
              {actionLabel}
            </Button>
          ) : null}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

Announcement

PropTypeDefaultDescription
titlerequiredReactNode
actionLabelReactNodeCTA label. When set, a full-width secondary button is rendered.
closeLabelstringDismissAccessible name for the dismiss button.
descriptionReactNode
dismissiblebooleanfalseShow a dismiss button in the top-right corner.
iconIconComponent
introDelaynumberSeconds to wait before playing the blur/fade/scale-up entrance. Omit to skip the entrance entirely (card just appears).
onAction() => void
onClose() => voidCalled once the dismiss exit animation finishes.