Link above a hero where a sheen crosses once and the chevron becomes an arrow.
Marketing@base-ui/react
01Preview
Review every change before it ships
Preview deploys for each pull request, with comments pinned to the pixel your teammate meant.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import { AnnouncementPill } from "@/components/ui/announcement-pill";
<AnnouncementPill href="/changelog/agent-mode" badge="New">
Agent mode is here
</AnnouncementPill>
// With your router's link, and a quieter chip.
<AnnouncementPill render={<Link href="/changelog/2-4" />} badge="v2.4" badgeVariant="soft" size="sm">
Cold starts are 40% faster
</AnnouncementPill>04Source
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@/lib/cn";
type Size = "sm" | "md";
type BadgeVariant = "solid" | "soft";
const isExternalHref = (href?: string) => !!href && /^(https?:)?\/\//.test(href);
export type AnnouncementPillProps = useRender.ComponentProps<"a"> & {
/** The short label in the leading chip: "New", "Beta", "v2.4". Leave it out for a plain pill. */
badge?: React.ReactNode;
/** A filled chip that catches the eye, or a quiet outlined one. */
badgeVariant?: BadgeVariant;
size?: Size;
/** Opens in a new tab with an up-right arrow. Defaults to true for absolute URLs. */
external?: boolean;
};
/**
* The link above a hero that says what's new. On hover a band of light crosses
* it once, left to right, and the chevron pulls a stem out behind it to become
* an arrow. Renders an anchor; pass `render={<Link href="…" />}` for a router link.
*/
export function AnnouncementPill({
badge,
badgeVariant = "solid",
size = "md",
external: externalProp,
render,
className,
children,
...rest
}: AnnouncementPillProps) {
const external = externalProp ?? isExternalHref(rest.href);
const sm = size === "sm";
const own: useRender.ElementProps<"a"> & Record<`data-${string}`, string | undefined> = {
"data-slot": "announcement-pill",
"data-size": size,
"data-badge": badge ? badgeVariant : undefined,
target: external ? "_blank" : undefined,
rel: external ? "noopener noreferrer" : undefined,
className: cn(
"group/pill relative isolate inline-flex max-w-full min-w-0 cursor-pointer select-none items-center rounded-full",
"border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]",
"touch-manipulation [-webkit-tap-highlight-color:transparent]",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
// One-step hover: the surface and the text each brighten a step. Press sinks it a touch.
"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart",
"hover:border-fg-4/70 hover:bg-hover hover:text-fg focus-visible:text-fg active:scale-[0.98] active:duration-75",
sm ? "h-7 gap-1.5 text-[12px]" : "h-8 gap-2 text-[12.5px]",
badge ? (sm ? "pl-[3px] pr-2" : "pl-1 pr-2.5") : sm ? "pl-2.5 pr-2" : "pl-3 pr-2.5",
// 28–32px tall to the eye, 44px to a thumb.
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2 pointer-coarse:after:content-['']",
className,
),
children: (
<>
{/* The sheen lives in its own clipped layer so the pill's touch area can reach past its edge. */}
<span aria-hidden className="pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-[inherit] motion-reduce:hidden">
<span
className={cn(
"absolute inset-y-0 left-0 w-1/2 -skew-x-[20deg]",
"bg-linear-to-r from-transparent via-raised to-transparent dark:via-fg/[0.09]",
// Crosses once on the way in (850ms), snaps home unseen on the way out (0ms),
// so leaving never plays it backwards.
"-translate-x-[120%] transition-[translate] duration-0 ease-in-out-quart",
"group-hover/pill:translate-x-[240%] group-hover/pill:duration-[850ms]",
"group-focus-visible/pill:translate-x-[240%] group-focus-visible/pill:duration-[850ms]",
)}
/>
</span>
{badge != null && badge !== false && (
<span
data-slot="announcement-pill-badge"
className={cn(
"inline-flex shrink-0 items-center rounded-full font-medium tracking-[-0.005em] transition-colors duration-150",
sm ? "h-5 px-1.5 text-[11px]" : "h-6 px-2 text-[11.5px]",
badgeVariant === "solid" ? "bg-fg text-frame" : "border border-line-2 bg-frame text-fg group-hover/pill:border-fg-4/70",
)}
>
{badge}
{/* Read as "New: Agent mode is here", not "New Agent mode is here". */}
<span className="sr-only">: </span>
</span>
)}
<span className="min-w-0 truncate font-[450] tracking-[-0.005em]">{children}</span>
<Arrow external={external} />
{external && <span className="sr-only"> (opens in a new tab)</span>}
</>
),
};
return useRender({ defaultTagName: "a", render, props: mergeProps<"a">(own, rest) });
}
/**
* At rest a chevron. On hover it steps 1.5px forward and a stem draws in behind
* it, so the mark turns into an arrow pointing where the link goes.
*/
function Arrow({ external }: { external: boolean }) {
const common = {
width: 14,
height: 14,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
focusable: false,
className: "-mr-0.5 shrink-0 text-fg-3 transition-colors duration-150 group-hover/pill:text-fg group-focus-visible/pill:text-fg",
};
const move = "transition-transform duration-200 ease-out-quart";
if (external)
return (
<svg {...common}>
<g className={cn(move, "group-hover/pill:translate-x-px group-hover/pill:-translate-y-px group-focus-visible/pill:translate-x-px group-focus-visible/pill:-translate-y-px")}>
<path d="M5 11 11 5M6 5h5v5" />
</g>
</svg>
);
return (
<svg {...common}>
<path
d="M3.75 8h6.5"
className={cn(
"origin-right [transform-box:fill-box] scale-x-0 opacity-0",
"transition-[scale,opacity,translate] duration-200 ease-out-quart",
"group-hover/pill:translate-x-[1.5px] group-hover/pill:scale-x-100 group-hover/pill:opacity-100",
"group-focus-visible/pill:translate-x-[1.5px] group-focus-visible/pill:scale-x-100 group-focus-visible/pill:opacity-100",
)}
/>
<path d="m7.25 4.5 3.5 3.5-3.5 3.5" className={cn(move, "group-hover/pill:translate-x-[1.5px] group-focus-visible/pill:translate-x-[1.5px]")} />
</svg>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| href | string | — | Where the announcement leads. Absolute URLs open in a new tab unless external is false. |
| badge | ReactNode | — | The short leading chip: New, Beta, a version. Omit it for a plain pill. |
| badgeVariant | "solid" | "soft" | "solid" | A filled chip that draws the eye, or an outlined one for secondary news. |
| size | "sm" | "md" | "md" | 28px or 32px tall. Touch screens get a 44px target either way. |
| external | boolean | — | Opens in a new tab with an up-right arrow and says so to screen readers. Defaults to true for absolute URLs. |
| render | ReactElement | (props, state) => ReactElement | — | Render as your router's link (render={<Link href="…" />}); it keeps the look and the content. |
06Notes
Behavior
- The message truncates with an ellipsis before the pill ever outgrows its container, so a long headline never wraps a hero onto two lines; the badge and arrow never shrink.
- The sheen runs once per hover or keyboard focus and snaps back unseen when you leave, so it never plays in reverse or loops.
- Renders an anchor by default. Absolute URLs get target=_blank with rel=noopener noreferrer and the up-right arrow in place of the chevron.
Motion
- The sheen is a skewed band crossing the pill in 850ms on the in-out quart curve, drawn behind the text so the words stay crisp. It is only feedback for a pointer or focus, never idle decoration.
- The chevron steps 1.5px forward while a stem scales in from its tip over 200ms on the quart ease-out, turning it into an arrow. External links nudge 1px up and right instead.
- Press scales to 0.98 in 75ms. Reduced motion hides the sheen entirely; the arrow and colors change on the same frame.
Accessibility
- A native link. The badge is followed by a visually hidden colon, so it reads as “New: Agent mode is here”.
- External links add “(opens in a new tab)” to the accessible name.
- Keyboard focus gets the same sheen, arrow and brightened text as hover, plus a 1px outline that follows the pill's radius.