ScrollX UI
uiannouncementsource: ok · hit
Announcement
An announcement component that highlights key info with icons, effects, and expandable content.
npx shadcn add "https://www.scrollxui.dev/registry/announcement.json"free
Where it goes
banner
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Registry dependencies
@scrollxui/badge@scrollxui/lustretext
Files4 · source cached 10/9/2026, 12:05:22 PM
components/ui/announcement.tsx
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
motion,
AnimatePresence,
useAnimationFrame,
useMotionTemplate,
useMotionValue,
useTransform,
} from 'motion/react';
import { ChevronDown } from 'lucide-react';
import LustreText from '@/components/ui/lustretext';
const EXPANDABLE_CONTENT_SYMBOL = Symbol.for('AnnouncementExpandedContent');
const MovingBorder = ({
children,
duration = 3000,
rx,
ry,
...otherProps
}: {
children: React.ReactNode;
duration?: number;
rx?: string;
ry?: string;
} & React.SVGProps<SVGSVGElement>) => {
const pathRef = useRef<SVGRectElement | null>(null);
const progress = useMotionValue(0);
useAnimationFrame((time) => {
const length = pathRef.current?.getTotalLength?.();
if (length) {
const pxPerMillisecond = length / duration;
progress.set((time * pxPerMillisecond) % length);
}
});
const x = useTransform(
progress,
(val) => pathRef.current?.getPointAtLength(val).x ?? 0,
);
const y = useTransform(
progress,
(val) => pathRef.current?.getPointAtLength(val).y ?? 0,
);
const transform = useMotionTemplate`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)`;
return (
<>
<svg
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='none'
className='absolute h-full w-full'
width='100%'
height='100%'
{...otherProps}
>
<rect
fill='none'
width='100%'
height='100%'
rx={rx}
ry={ry}
ref={pathRef}
/>
</svg>
<motion.div
style={{
position: 'absolute',
top: 0,
left: 0,
display: 'inline-block',
transform,
}}
>
{children}
</motion.div>
</>
);
};
export type AnnouncementProps = Omit<ComponentProps<typeof Badge>, 'ref'> & {
styled?: boolean;
animation?: 'fade';
icon?: ReactNode;
iconPosition?: 'left' | 'right';
shiny?: boolean;
movingBorder?: boolean;
movingBorderDuration?: number;
movingBorderClassName?: string;
};
function AnnouncementComponent({
variant = 'outline',
styled = false,
animation = 'fade',
icon,
iconPosition = 'left',
shiny = false,
movingBorder = false,
movingBorderDuration = 3000,
movingBorderClassName,
className,
children,
...props
}: AnnouncementProps) {
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isMounted = typeof window !== 'undefined';
const { expandedContent, mainContent, hasExpandable } = React.useMemo(() => {
const childArray = React.Children.toArray(children);
const main: ReactNode[] = [];
let expanded: ReactNode = null;
let found = false;
childArray.forEach((child) => {
if (
React.isValidElement(child) &&
(child.type as unknown as { [key: symbol]: boolean })?.[
EXPANDABLE_CONTENT_SYMBOL
]
) {
expanded = (child.props as { children?: ReactNode }).children;
found = true;
} else {
main.push(child);
}
});
return {
expandedContent: expanded,
mainContent: main,
hasExpandable: found,
};
}, [children]);
const updatePosition = useCallback(() => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
}, []);
useEffect(() => {
if (!isOpen || !hasExpandable) return;
updatePosition();
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
containerRef.current &&
!containerRef.current.contains(target) &&
dropdownRef.current &&
!dropdownRef.current.contains(target)
) {
setIsOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
const handleScroll = () => {
updatePosition();
};
const handleResize = () => {
updatePosition();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
}, [isOpen, hasExpandable, updatePosition]);
const animations = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
};
const displayContent = shiny
? React.Children.map(mainContent, (child) =>
typeof child === 'string' ? <LustreText text={child} /> : child,
)
: mainContent;
const handleToggle = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsOpen((prev) => !prev);
}, []);
const badgeContent = (
<Badge
className={cn(
'group relative max-w-full overflow-hidden rounded-full bg-background px-4 py-1.5 font-medium shadow-xs',
styled && 'border-foreground/5',
className,
)}
variant={variant}
data-expandable={hasExpandable}
{...props}
>
<div className='relative flex items-center gap-2'>
{icon && iconPosition === 'left' && (
<span className='shrink-0'>{icon}</span>
)}
<div className='flex-1 flex items-center gap-2 truncate'>
{displayContent}
</div>
{icon && iconPosition === 'right' && (
<span className='shrink-0'>{icon}</span>
)}
{hasExpandable ? (
<button
type='button'
onClick={handleToggle}
data-state={isOpen ? 'open' : 'closed'}
className='flex shrink-0 items-center rounded p-1 hover:bg-foreground/10 transition-colors focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ml-1'
aria-expanded={isOpen}
aria-haspopup='true'
aria-label={
isOpen ? 'Collapse announcement' : 'Expand announcement'
}
>
<ChevronDown
className='size-3 shrink-0 transition-transform duration-300 data-[state=open]:rotate-180'
aria-hidden='true'
data-state={isOpen ? 'open' : 'closed'}
/>
</button>
) : null}
</div>
</Badge>
);
return (
<>
<motion.div
ref={containerRef}
data-state={isOpen ? 'open' : 'closed'}
{...animations[animation]}
transition={{ duration: 0.3, ease: 'easeOut' }}
className='relative inline-block'
>
{movingBorder ? (
<div className='relative overflow-hidden rounded-full bg-transparent p-px'>
<div className='absolute inset-0 pointer-events-none'>
<MovingBorder duration={movingBorderDuration} rx='50%' ry='50%'>
<div
className={cn(
'h-20 w-20 bg-[radial-gradient(#0ea5e9_40%,transparent_60%)] opacity-[0.8]',
movingBorderClassName,
)}
/>
</MovingBorder>
</div>
{badgeContent}
</div>
) : (
badgeContent
)}
</motion.div>
{isMounted &&
hasExpandable &&
createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
style={{
position: 'absolute',
top: `${dropdownPosition.top + 8}px`,
left: `${dropdownPosition.left}px`,
width: `${Math.max(dropdownPosition.width, 200)}px`,
zIndex: 50,
}}
className='rounded-lg border bg-popover text-popover-foreground p-4 text-sm shadow-lg'
role='dialog'
aria-modal='false'
aria-label='Expanded announcement content'
>
{expandedContent}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
export const Announcement = AnnouncementComponent;
export type AnnouncementTagProps = HTMLAttributes<HTMLSpanElement> & {
lustre?: boolean;
movingBorder?: boolean;
movingBorderDuration?: number;
movingBorderClassName?: string;
};
export function AnnouncementTag({
className,
lustre = false,
movingBorder = false,
movingBorderDuration = 3000,
movingBorderClassName,
children,
...props
}: AnnouncementTagProps) {
const tagContent = (
<span
className={cn(
'relative inline-flex items-center gap-2 px-3 py-1 text-xs font-semibold rounded-full bg-background',
className,
)}
{...props}
>
<span className='absolute inset-0 rounded-full bg-foreground/5 opacity-70 pointer-events-none' />
<span className='relative z-10'>
{React.Children.map(children, (child) =>
lustre && typeof child === 'string' ? (
<LustreText text={child} />
) : (
child
),
)}
</span>
</span>
);
if (movingBorder) {
return (
<span className='relative inline-block overflow-hidden rounded-full p-px'>
<span className='absolute inset-0 pointer-events-none'>
<MovingBorder duration={movingBorderDuration} rx='50%' ry='50%'>
<div
className={cn(
'h-12 w-12 bg-[radial-gradient(#0ea5e9_40%,transparent_60%)] opacity-80',
movingBorderClassName,
)}
/>
</MovingBorder>
</span>
<span className='relative'>{tagContent}</span>
</span>
);
}
return tagContent;
}
export type AnnouncementTitleProps = HTMLAttributes<HTMLSpanElement> & {
multiTags?: boolean;
lustre?: boolean;
};
export function AnnouncementTitle({
className,
multiTags = false,
lustre = false,
children,
...props
}: AnnouncementTitleProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 py-1',
multiTags ? 'flex-wrap' : 'truncate',
className,
)}
{...props}
>
{React.Children.map(children, (child) =>
lustre && typeof child === 'string' ? (
<LustreText text={child} />
) : (
child
),
)}
</span>
);
}
export type AnnouncementContainerProps = HTMLAttributes<HTMLDivElement>;
export function AnnouncementContainer({
className,
...props
}: AnnouncementContainerProps) {
return (
<div
className={cn('flex flex-wrap items-center gap-1.5', className)}
{...props}
/>
);
}
export function AnnouncementExpandedContent({
children,
}: {
children: ReactNode;
}) {
return null;
}
(AnnouncementExpandedContent as unknown as { [key: symbol]: boolean })[
EXPANDABLE_CONTENT_SYMBOL
] = true;
components/ui/badge.tsx
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
shiny: {
true: 'relative overflow-hidden',
false: '',
},
},
defaultVariants: {
variant: 'default',
shiny: false,
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
shiny?: boolean;
shinySpeed?: number;
}
function Badge({
className,
variant,
shiny = false,
shinySpeed = 5,
children,
...props
}: BadgeProps) {
const animationDuration = `${shinySpeed}s`;
return (
<div
className={cn(badgeVariants({ variant, shiny }), className)}
{...props}
>
<span className={shiny ? 'relative z-10' : ''}>{children}</span>
{shiny && (
<span
className='absolute inset-0 pointer-events-none animate-shine dark:hidden'
style={{
background:
'linear-gradient(120deg, transparent 40%, rgba(255,255,255,0.6) 50%, transparent 60%)',
backgroundSize: '200% 100%',
animationDuration,
mixBlendMode: 'screen',
}}
/>
)}
{shiny && (
<span
className='absolute inset-0 pointer-events-none animate-shine hidden dark:block'
style={{
background:
'linear-gradient(120deg, transparent 40%, rgba(0,0,150,0.25) 50%, transparent 60%)',
backgroundSize: '200% 100%',
animationDuration,
mixBlendMode: 'multiply',
}}
/>
)}
</div>
);
}
export { Badge, badgeVariants };
components/ui/lustretext.tsx
import React from "react";
interface LustreTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
}
const LustreText: React.FC<LustreTextProps> = ({
text,
disabled = false,
speed = 5,
className = "",
}) => {
const animationStyle = {
animationDuration: `${speed}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationFillMode: "forwards",
};
return (
<span
className={`
lustre-text
${!disabled ? "animate-shine" : ""}
dark:lustre-dark lustre-light
${className}
`}
style={!disabled ? animationStyle : undefined}
>
{text}
</span>
);
};
export default LustreText;
lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}