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.jsonnpx shadcn@latest add https://boardcn.dev/r/announcement.jsonnpm 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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| titlerequired | ReactNode | — | — |
| actionLabel | ReactNode | — | CTA label. When set, a full-width secondary button is rendered. |
| closeLabel | string | Dismiss | Accessible name for the dismiss button. |
| description | ReactNode | — | — |
| dismissible | boolean | false | Show a dismiss button in the top-right corner. |
| icon | IconComponent | — | — |
| introDelay | number | — | Seconds to wait before playing the blur/fade/scale-up entrance. Omit to skip the entrance entirely (card just appears). |
| onAction | () => void | — | — |
| onClose | () => void | — | Called once the dismiss exit animation finishes. |