+ );
+}
+
+export default AnnouncementCard;
diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx
new file mode 100644
index 00000000000..57f9dcc5d01
--- /dev/null
+++ b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { AnnouncementCarousel } from './AnnouncementCarousel';
+import { AnnouncementCardVariant } from './types';
+import type { AnnouncementItem } from './types';
+
+const items: AnnouncementItem[] = [
+ {
+ id: 'a',
+ variant: AnnouncementCardVariant.Default,
+ badge: { label: 'New' },
+ title: 'First card',
+ description: 'first description',
+ cta: { text: 'Go', onClick: jest.fn() },
+ },
+ {
+ id: 'b',
+ variant: AnnouncementCardVariant.Default,
+ title: 'Second card',
+ },
+ {
+ id: 'c',
+ variant: AnnouncementCardVariant.Compact,
+ title: 'Third card',
+ href: '#',
+ },
+];
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ act(() => {
+ jest.runOnlyPendingTimers();
+ });
+ jest.useRealTimers();
+});
+
+const getDots = () =>
+ screen.getAllByRole('button', { name: /Show announcement/ });
+
+describe('AnnouncementCarousel', () => {
+ it('renders the first card and one dot per item', () => {
+ render();
+
+ expect(screen.getByText('First card')).toBeInTheDocument();
+ expect(getDots()).toHaveLength(3);
+ });
+
+ it('renders nothing when there are no items', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('switches the active card when a dot is hovered', () => {
+ render();
+
+ fireEvent.mouseOver(getDots()[1]);
+
+ expect(screen.getByText('Second card')).toBeInTheDocument();
+ expect(screen.queryByText('First card')).not.toBeInTheDocument();
+ });
+
+ it('dismisses the active card and reports it to the parent', () => {
+ const onDismiss = jest.fn();
+ render();
+
+ fireEvent.click(screen.getByTitle('Close'));
+ act(() => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(onDismiss).toHaveBeenCalledWith('a');
+ });
+
+ it('logs an impression once per card after a dwell, not on rapid scrub', () => {
+ const onView = jest.fn();
+ render(
+ ,
+ );
+
+ // The first card settles and logs a single impression.
+ act(() => {
+ jest.advanceTimersByTime(400);
+ });
+ expect(onView).toHaveBeenCalledTimes(1);
+ expect(onView).toHaveBeenLastCalledWith(
+ expect.objectContaining({ id: 'a' }),
+ );
+
+ // Scrub past 'b' to 'c' without dwelling — only the settled card logs.
+ fireEvent.mouseOver(getDots()[1]);
+ fireEvent.mouseOver(getDots()[2]);
+ act(() => {
+ jest.advanceTimersByTime(400);
+ });
+
+ expect(onView).toHaveBeenCalledTimes(2);
+ expect(onView).toHaveBeenLastCalledWith(
+ expect.objectContaining({ id: 'c' }),
+ );
+ });
+});
diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx
new file mode 100644
index 00000000000..845ef6caee7
--- /dev/null
+++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx
@@ -0,0 +1,176 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { AnnouncementCard } from './AnnouncementCard';
+import { AnnouncementCardVariant } from './types';
+import type { AnnouncementItem } from './types';
+
+const EXIT_MS = 200;
+// How long a card must stay active before it counts as a genuine impression —
+// long enough that hovering across the dots to browse doesn't log every card.
+const VIEW_DWELL_MS = 400;
+
+const prefersReducedMotion = (): boolean =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+export interface AnnouncementCarouselProps {
+ items: AnnouncementItem[];
+ onDismiss: (id: string) => void;
+ // Fired when a card becomes the active (visible) one — for impressions.
+ onView?: (item: AnnouncementItem) => void;
+ // Fired when a Compact card's row is clicked.
+ onItemClick?: (item: AnnouncementItem) => void;
+ className?: string;
+}
+
+export function AnnouncementCarousel({
+ items,
+ onDismiss,
+ onView,
+ onItemClick,
+ className,
+}: AnnouncementCarouselProps): ReactElement | null {
+ const [active, setActive] = useState(0);
+ const [exitingId, setExitingId] = useState(null);
+ const exitTimer = useRef>();
+ const viewTimer = useRef>();
+ // Ids already counted as viewed, so scrubbing back and forth never
+ // double-logs an impression.
+ const viewedRef = useRef>(new Set());
+
+ const count = items.length;
+ // Items shrink as cards are dismissed; keep the index in range.
+ const safeActive = Math.min(active, Math.max(count - 1, 0));
+ const current = items[safeActive];
+
+ // Log an impression only after a card has settled (dwell) and only once —
+ // hovering across the dots to browse the stack shouldn't fire impressions.
+ useEffect(() => {
+ const id = current?.id;
+ if (!id || viewedRef.current.has(id)) {
+ return undefined;
+ }
+ viewTimer.current = setTimeout(() => {
+ viewedRef.current.add(id);
+ onView?.(current);
+ }, VIEW_DWELL_MS);
+ return () => clearTimeout(viewTimer.current);
+ // Re-run only when the visible card changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [current?.id]);
+
+ useEffect(
+ () => () => {
+ clearTimeout(exitTimer.current);
+ clearTimeout(viewTimer.current);
+ },
+ [],
+ );
+
+ if (!current) {
+ return null;
+ }
+
+ const goTo = (index: number): void => {
+ setActive(Math.max(0, Math.min(count - 1, index)));
+ };
+
+ const handleDismiss = (id: string): void => {
+ // Animate the card out, then let the parent remove it so the next card
+ // slides into its place. Skip the delay when motion is reduced.
+ if (prefersReducedMotion()) {
+ onDismiss(id);
+ return;
+ }
+ setExitingId(id);
+ exitTimer.current = setTimeout(() => {
+ setExitingId(null);
+ onDismiss(id);
+ }, EXIT_MS);
+ };
+
+ const hasMultiple = count > 1;
+ const isExiting = exitingId === current.id;
+
+ return (
+
+
+ {hasMultiple && (
+ // A single card peeking behind, occluded by the active card so only
+ // its bottom edge shows — like a notification stack. On hover the
+ // stack "opens": the card behind slides out and brightens.
+
+ )}
+
+
+ {hasMultiple && (
+ // Centered segmented indicator. Hovering or focusing a dot switches to
+ // that announcement (this lives in the desktop sidebar; click/focus
+ // cover keyboard and any non-hover input). The active dot stretches
+ // into a pill and the fill glides between positions.
+
+ Sixteen ways to present an announcement, all rendered at the real
+ ~240px sidebar width. Every image is full-bleed and every gradient/scrim
+ spans edge to edge. Pick a direction (or a couple to mix) and I'll
+ wire it into the live component.
+
+
+ {DIRECTIONS.map(({ label, note, node }) => (
+
+
+ {label}
+
+ {node}
+ {note}
+
+ ))}
+
+
+ ),
+};
diff --git a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx
new file mode 100644
index 00000000000..3b7aacd615f
--- /dev/null
+++ b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx
@@ -0,0 +1,316 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AnnouncementCard } from '@dailydotdev/shared/src/components/announcements/AnnouncementCard';
+import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types';
+import { HotIcon } from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image';
+
+const meta: Meta = {
+ title: 'Components/Announcements/Guidelines',
+ parameters: {
+ layout: 'fullscreen',
+ backgrounds: { default: 'dark' },
+ controls: { disable: true },
+ },
+};
+
+export default meta;
+
+const H = ({ children }: { children: ReactNode }): ReactElement => (
+
{children}
+);
+
+const Lead = ({ children }: { children: ReactNode }): ReactElement => (
+
+
+ A Linear-style “what’s new” surface for the bottom of the sidebar.
+ Use it to prominently announce a new feature or release and drive one
+ clear action. Cards stack into a browsable carousel, carry an
+ optional “New/Beta” badge, and are dismissible — once dismissed, a
+ card stays gone (persisted client-side by id). Keep the list short
+ and high-signal: show fewer, better entries.
+
+
+ The three tiers
+
+ Match the tier to the weight of the update. Minor → Compact, standard
+ release → Default, headline launch → Cover.
+
+
+
+ One primary CTA per card — ButtonVariant.Primary,{' '}
+ ButtonSize.Small, left-aligned under the body.
+
+
+ Dismiss is a CloseButton (XSmall) top-right.
+ Over a cover image use ButtonVariant.Primary (Float is
+ invisible on imagery); on the solid Default card the Tertiary
+ default is fine.
+
+
+ Persist dismissal by id so a closed card never returns
+ (handled by useSidebarAnnouncements).
+
+
+ Don’t stack multiple CTAs or add a separate text “Dismiss” button.
+
+
+ Don’t make a Compact row individually dismissible — it’s a single
+ link target (nested buttons are invalid); reserve × for
+ Default/Cover.
+
+
+
+ Do & don’t
+
+ Keep titles to one line; let the body carry detail (2 lines max).
+ Show 1–4 curated entries, newest first.
+ Hide the whole surface when the sidebar is collapsed or empty.
+ Don’t use a Cover image just to fill space — only for true headline launches.
+ Don’t leave an empty container when there’s nothing to show (render nothing).
+
+
+
+ ),
+};
diff --git a/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx
new file mode 100644
index 00000000000..a433e459df8
--- /dev/null
+++ b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx
@@ -0,0 +1,271 @@
+import type { ReactElement } from 'react';
+import React, { useMemo, useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AnnouncementCarousel } from '@dailydotdev/shared/src/components/announcements/AnnouncementCarousel';
+import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types';
+import type { AnnouncementItem } from '@dailydotdev/shared/src/components/announcements/types';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import {
+ BellIcon,
+ HotIcon,
+ MagicIcon,
+ MegaphoneIcon,
+ SparkleIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image';
+
+const seed = (): AnnouncementItem[] => [
+ {
+ id: 'clips',
+ variant: AnnouncementCardVariant.Cover,
+ image: cloudinarySquadsDirectoryCardBannerDefault,
+ badge: { label: 'New' },
+ title: 'Introducing Clips',
+ description:
+ 'Save the best moments from any post and share them in one tap.',
+ cta: { text: 'Try Clips', href: '#' },
+ },
+ {
+ id: 'custom-feeds',
+ variant: AnnouncementCardVariant.Default,
+ badge: { label: 'Updated' },
+ title: 'Smarter custom feeds',
+ description:
+ 'Custom feeds now learn from what you read to surface more of what matters.',
+ cta: { text: 'See what changed', href: '#' },
+ },
+ {
+ id: 'shortcuts',
+ variant: AnnouncementCardVariant.Compact,
+ icon: ,
+ title: 'Keyboard shortcuts are here',
+ href: '#',
+ },
+ {
+ id: 'digest',
+ variant: AnnouncementCardVariant.Default,
+ badge: { label: 'Beta' },
+ title: 'Your weekly digest',
+ description: 'A personalized briefing of the best posts, every Monday.',
+ cta: { text: 'Enable digest', href: '#' },
+ },
+ {
+ id: 'changelog',
+ variant: AnnouncementCardVariant.Compact,
+ icon: ,
+ title: 'Catch up on everything new',
+ description: 'Browse the full changelog',
+ href: '#',
+ },
+];
+
+const extras: AnnouncementItem[] = [
+ {
+ id: 'extra-magic',
+ variant: AnnouncementCardVariant.Default,
+ badge: { label: 'New' },
+ icon: ,
+ title: 'AI-powered summaries',
+ description: 'Get the gist of any long read in two lines.',
+ cta: { text: 'Try it', href: '#' },
+ },
+ {
+ id: 'extra-bell',
+ variant: AnnouncementCardVariant.Compact,
+ icon: ,
+ title: 'Smarter notifications',
+ href: '#',
+ },
+ {
+ id: 'extra-sparkle',
+ variant: AnnouncementCardVariant.Cover,
+ image: cloudinarySquadsDirectoryCardBannerDefault,
+ badge: { label: 'New' },
+ icon: ,
+ title: 'A fresh reading theme',
+ description: 'Easier on the eyes during those late-night sessions.',
+ cta: { text: 'Preview', href: '#' },
+ },
+];
+
+const Chip = ({ children }: { children: string }): ReactElement => (
+
+ {children}
+
+);
+
+const Playground = (): ReactElement => {
+ const [items, setItems] = useState(seed);
+ const [log, setLog] = useState([]);
+ const [extraIndex, setExtraIndex] = useState(0);
+
+ const pushLog = (line: string): void =>
+ setLog((prev) => [line, ...prev].slice(0, 8));
+
+ const remaining = items.length;
+ const addOne = (): void => {
+ const next = extras[extraIndex % extras.length];
+ const id = `${next.id}-${extraIndex}`;
+ setItems((prev) => [...prev, { ...next, id }]);
+ setExtraIndex((i) => i + 1);
+ pushLog(`added · ${id}`);
+ };
+
+ const sidebarBg = useMemo(
+ () =>
+ 'flex w-[17rem] flex-col rounded-16 border border-border-subtlest-tertiary bg-background-default p-3',
+ [],
+ );
+
+ return (
+
+ {/* Sidebar-like panel hosting the real carousel */}
+