Announcement

PreviousNext

An announcement component that highlights key info with icons, effects, and expandable content.

Docs
scrollxuicomponent

Preview

Loading preview…
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 'framer-motion';
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 [isMounted, setIsMounted] = useState(false);
  const [hasExpandable, setHasExpandable] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);
  const expandedContentRef = useRef<ReactNode>(null);
  const mainContentRef = useRef<ReactNode[]>([]);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  useEffect(() => {
    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 Record<symbol, boolean>)[EXPANDABLE_CONTENT_SYMBOL]) {
        expanded = child.props.children;
        found = true;
      } else {
        main.push(child);
      }
    });

    expandedContentRef.current = expanded;
    mainContentRef.current = main;
    setHasExpandable(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(mainContentRef.current, (child) =>
        typeof child === 'string' ? <LustreText text={child} /> : child
      )
    : mainContentRef.current;

  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-sm',
        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-none 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-[1px]">
            <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"
            >
              {expandedContentRef.current}
            </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-[1px]">
        <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 Record<symbol, boolean>)[EXPANDABLE_CONTENT_SYMBOL] = true;

Installation

npx shadcn@latest add @scrollxui/announcement

Usage

import { Announcement } from "@/components/announcement"
<Announcement />