shadimage
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 · medium
animationmotion
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));
}

More from ScrollX UI

Browse all components in this library →