Todos os componentes

Linear Modal Standalone

Linear Modal Standalone é um componente de sobreposição para React e Tailwind CSS, da biblioteca UI Layouts, com licença MIT. Copia e cola no teu projeto.

UI Layouts MIT overlays

O que é

Linear Modal Standalone é um componente de sobreposição para React e Tailwind CSS, da biblioteca UI Layouts, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion lucide-react

Licença

Este componente vem de UI Layouts e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Linear Modal Standalone

./registry/components/linear-modal/standalone-version.tsx
'use client';
import { Plus, X } from 'lucide-react';
import { AnimatePresence, MotionConfig, type Transition, motion } from 'motion/react';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

// Card item type
interface CardItem {
  id: number;
  url: string;
  title: string;
  description: string;
  tags: string[];
}

// Animation config
const transition: Transition = {
  type: 'spring',
  bounce: 0.05,
  duration: 0.3,
};

const LinearCardDialog: React.FC = () => {
  const [index, setIndex] = useState<number>(0);
  const [isOpen, setIsOpen] = useState<boolean>(false);
  const [carouselWidth, setCarouselWidth] = useState<number>(0);
  const carousel = useRef<HTMLDivElement>(null);

  // Default items (standalone, fixed)
  const items: CardItem[] = [
    {
      id: 1,
      url: 'https://images.unsplash.com/photo-1757672242146-a6a7897bcc80?q=80&w=1171&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Accordion',
      description: 'Immerse yourself in our cutting-edge interactive gallery...',
      tags: ['Sunrise', 'Mountains', 'Golden', 'Scenic', 'Inspiring'],
    },
    {
      id: 2,
      url: 'https://images.unsplash.com/photo-1756806983725-977bb2308d4e?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Globe Section',
      description:
        'Embark on a virtual journey around the world with our state-of-the-art 3D globe feature...',
      tags: ['Misty', 'Path', 'Mysterious', 'Serene', 'Rugged'],
    },
    {
      id: 3,
      url: 'https://images.unsplash.com/photo-1756806983832-1f056cf24182?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
      title: 'Image Mouse Trail',
      description:
        'Transform your browsing experience with our mesmerizing Image Mouse Trail feature...',
      tags: ['Pathway', 'Adventure', 'Peaks', 'Challenging', 'Breathtaking'],
    },
  ];

  useEffect(() => {
    if (isOpen) {
      document.body.classList.add('overflow-hidden');
    } else {
      document.body.classList.remove('overflow-hidden');
    }

    const handleKeyDown = (event: KeyboardEvent): void => {
      if (event.key === 'Escape' && isOpen) {
        handleCloseDialog();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.classList.remove('overflow-hidden');
    };
  }, [isOpen]);

  useEffect(() => {
    if (carousel.current) {
      const scrollWidth = carousel.current.scrollWidth;
      const offsetWidth = carousel.current.offsetWidth;
      setCarouselWidth(scrollWidth - offsetWidth);
    }
  }, [items]);

  const handleCardClick = (itemIndex: number): void => {
    setIndex(itemIndex);
    setIsOpen(true);
  };

  const handleCloseDialog = (): void => {
    setIsOpen(false);
  };

  const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>): void => {
    if (e.target === e.currentTarget) {
      handleCloseDialog();
    }
  };

  const currentItem = items[index];

  return (
    <div className='relative h-full p-4'>
      <MotionConfig transition={transition}>
        <motion.div
          ref={carousel}
          drag='x'
          dragElastic={0.2}
          dragConstraints={{ right: 0, left: -carouselWidth }}
          dragTransition={{ bounceDamping: 30 }}
          className='flex w-full gap-4 py-10'
        >
          {items.map((item, i) => (
            <motion.div
              key={item.id}
              className='shrink-0 flex relative flex-col overflow-hidden border dark:bg-black bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-950 cursor-pointer transition-colors'
              layoutId={`dialog-${item.id}`}
              style={{ width: '250px', borderRadius: '12px' }}
              tabIndex={i}
              onClick={() => handleCardClick(i)}
              onKeyDown={(e: React.KeyboardEvent) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.preventDefault();
                  handleCardClick(i);
                }
              }}
              role='button'
              aria-label={`Open ${item.title} details`}
            >
              <motion.div layoutId={`dialog-img-${item.id}`}>
                <img
                  src={item.url}
                  alt={item.title}
                  className='w-full h-48 object-cover'
                  loading='lazy'
                />
              </motion.div>
              <div className='flex grow flex-row items-end justify-between p-4'>
                <div className='flex-1'>
                  <motion.h3
                    layoutId={`dialog-title-${item.id}`}
                    className='dark:text-white text-black font-semibold text-sm truncate'
                  >
                    {item.title}
                  </motion.h3>
                </div>
                <button
                  className='absolute bottom-2 right-2 p-2 dark:bg-neutral-800 bg-neutral-300 hover:bg-neutral-400 dark:hover:bg-neutral-700 rounded-xl transition-colors'
                  aria-label={`Open ${item.title}`}
                  tabIndex={-1}
                >
                  <Plus className='w-4 h-4' />
                </button>
              </div>
            </motion.div>
          ))}
        </motion.div>

        {isOpen &&
          currentItem &&
          createPortal(
            <AnimatePresence initial={false} mode='sync'>
              <motion.div
                key={`backdrop-${currentItem.id}`}
                className='fixed inset-0 h-full w-full dark:bg-black/50 bg-black/25 backdrop-blur-xs z-40'
                variants={{ open: { opacity: 1 }, closed: { opacity: 0 } }}
                initial='closed'
                animate='open'
                exit='closed'
                onClick={handleBackdropClick}
              />
              <motion.div
                key='dialog'
                className='pointer-events-none fixed inset-0 flex items-center justify-center z-50 p-4'
              >
                <motion.div
                  className='pointer-events-auto relative flex h-auto w-full max-w-[500px] flex-col overflow-hidden dark:bg-neutral-900 bg-white border shadow-2xl max-h-[90vh] overflow-y-auto'
                  layoutId={`dialog-${currentItem.id}`}
                  tabIndex={-1}
                  style={{ borderRadius: '24px' }}
                  role='dialog'
                  aria-labelledby={`dialog-title-${currentItem.id}`}
                  aria-describedby={`dialog-description-${currentItem.id}`}
                >
                  <motion.div layoutId={`dialog-img-${currentItem.id}`}>
                    <img
                      src={currentItem.url}
                      alt={currentItem.title}
                      className='h-64 w-full object-cover'
                    />
                  </motion.div>
                  <div className='p-6'>
                    <motion.h2
                      layoutId={`dialog-title-${currentItem.id}`}
                      className='text-2xl font-bold text-zinc-950 dark:text-zinc-50 mb-4'
                      id={`dialog-title-${currentItem.id}`}
                    >
                      {currentItem.title}
                    </motion.h2>
                    <motion.div
                      initial={{ opacity: 0, y: 20 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: 20 }}
                      transition={{ delay: 0.1 }}
                      className='text-zinc-700 dark:text-zinc-300 leading-relaxed'
                      id={`dialog-description-${currentItem.id}`}
                    >
                      {currentItem.description}
                    </motion.div>
                    {currentItem.tags.length > 0 && (
                      <div className='flex flex-wrap gap-2 mt-4'>
                        {currentItem.tags.map((tag, tagIndex) => (
                          <span
                            key={`${tag}-${tagIndex}`}
                            className='px-2 py-1 text-xs bg-neutral-200 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 rounded-full'
                          >
                            {tag}
                          </span>
                        ))}
                      </div>
                    )}
                  </div>
                  <button
                    onClick={handleCloseDialog}
                    className='absolute right-4 top-4 p-2 bg-black dark:bg-white rounded-lg text-primary-foreground border cursor-pointer transition-colors'
                    type='button'
                    aria-label={`Close ${currentItem.title} dialog`}
                  >
                    <X size={20} />
                  </button>
                </motion.div>
              </motion.div>
            </AnimatePresence>,
            document.body
          )}
      </MotionConfig>
    </div>
  );
};

export default LinearCardDialog;
./components/ui/linear-modal.tsx
'use client';

import { cn } from '@/lib/utils';
import { XIcon } from 'lucide-react';
import { AnimatePresence, MotionConfig, type Transition, type Variant, motion } from 'motion/react';
import React, { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

interface DialogContextType {
  isOpen: boolean;
  setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
  uniqueId: string;
  triggerRef: React.RefObject<HTMLDivElement>;
}

const DialogContext = React.createContext<DialogContextType | null>(null);

function useDialog() {
  const context = useContext(DialogContext);
  if (!context) {
    throw new Error('useDialog must be used within a DialogProvider');
  }
  return context;
}

type DialogProviderProps = {
  children: React.ReactNode;
  transition?: Transition;
};

function DialogProvider({ children, transition }: DialogProviderProps) {
  const [isOpen, setIsOpen] = useState(false);
  const uniqueId = useId();
  const triggerRef = useRef<HTMLDivElement>(null);

  const contextValue = useMemo(
    () => ({ isOpen, setIsOpen, uniqueId, triggerRef }),
    [isOpen, uniqueId]
  );

  return (
    <DialogContext.Provider
      //@ts-expect-error
      value={contextValue}
    >
      <MotionConfig transition={transition}>{children}</MotionConfig>
    </DialogContext.Provider>
  );
}

type DialogProps = {
  children: React.ReactNode;
  transition?: Transition;
};

function Dialog({ children, transition }: DialogProps) {
  return (
    <DialogProvider>
      <MotionConfig transition={transition}>{children}</MotionConfig>
    </DialogProvider>
  );
}

type DialogTriggerProps = {
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
  triggerRef?: React.RefObject<HTMLDivElement>;
};

function DialogTrigger({ children, className, style, triggerRef }: DialogTriggerProps) {
  const { setIsOpen, isOpen, uniqueId } = useDialog();

  const handleClick = useCallback(() => {
    setIsOpen(!isOpen);
  }, [isOpen, setIsOpen]);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        setIsOpen(!isOpen);
      }
    },
    [isOpen, setIsOpen]
  );

  return (
    <motion.div
      ref={triggerRef}
      layoutId={`dialog-${uniqueId}`}
      className={cn('relative cursor-pointer', className)}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      style={style}
      role='button'
      aria-haspopup='dialog'
      aria-expanded={isOpen}
      aria-controls={`dialog-content-${uniqueId}`}
    >
      {children}
    </motion.div>
  );
}

type DialogContent = {
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
};

function DialogContent({ children, className, style }: DialogContent) {
  const { setIsOpen, isOpen, uniqueId, triggerRef } = useDialog();
  const containerRef = useRef<HTMLDivElement>(null);
  const [firstFocusableElement, setFirstFocusableElement] = useState<HTMLElement | null>(null);
  const [lastFocusableElement, setLastFocusableElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setIsOpen(false);
      }
      if (event.key === 'Tab') {
        if (!firstFocusableElement || !lastFocusableElement) return;

        if (event.shiftKey) {
          if (document.activeElement === firstFocusableElement) {
            event.preventDefault();
            lastFocusableElement.focus();
          }
        } else {
          if (document.activeElement === lastFocusableElement) {
            event.preventDefault();
            firstFocusableElement.focus();
          }
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [setIsOpen, firstFocusableElement, lastFocusableElement]);

  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';

      const focusableElements = containerRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (focusableElements && focusableElements.length > 0) {
        setFirstFocusableElement(focusableElements[0] as HTMLElement);
        setLastFocusableElement(focusableElements[focusableElements.length - 1] as HTMLElement);
        // Delay focus slightly to allow animation to start
        requestAnimationFrame(() => {
          (focusableElements[0] as HTMLElement).focus();
        });
      }

      if (containerRef.current) {
        containerRef.current.scrollTop = 0;
      }
    } else {
      document.body.style.overflow = '';
      triggerRef.current?.focus();
    }
  }, [isOpen, triggerRef]);

  return (
    <>
      <motion.div
        ref={containerRef}
        layoutId={`dialog-${uniqueId}`}
        className={cn('overflow-hidden', className)}
        style={{
          ...style,
          willChange: 'transform, opacity', // GPU acceleration
        }}
        role='dialog'
        aria-modal='true'
        aria-labelledby={`dialog-title-${uniqueId}`}
        aria-describedby={`dialog-description-${uniqueId}`}
        initial={{ scale: 0.95, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.95, opacity: 0 }}
        transition={{
          type: 'spring',
          damping: 25,
          stiffness: 300,
          mass: 0.8,
        }}
      >
        {children}
      </motion.div>
    </>
  );
}
type DialogContainerProps = {
  children: React.ReactNode;
  className?: string;
  overlayClassName?: string;
  style?: React.CSSProperties;
};

function DialogContainer({ children, className, overlayClassName }: DialogContainerProps) {
  const { isOpen, setIsOpen, uniqueId } = useDialog();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const drawerWrapper = document.querySelectorAll('[drawer-wrapper]');

    if (isOpen) {
      document.body.classList.add('overflow-hidden');
      drawerWrapper.forEach((wrapper) => wrapper?.classList.add('open'));
    } else {
      document.body.classList.remove('overflow-hidden');
      drawerWrapper.forEach((wrapper) => wrapper?.classList.remove('open'));
    }
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setIsOpen(false);
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [isOpen]);

  useEffect(() => {
    setMounted(true);
    return () => {
      setMounted(false);
    };
  }, []);

  if (!mounted) return null;

  return createPortal(
    <AnimatePresence initial={false} mode='wait'>
      {isOpen && (
        <>
          <motion.div
            key={`backdrop-${uniqueId}`}
            data-lenis-prevent
            className={cn(
              'fixed inset-0 h-full z-50 w-full backdrop-blur-xl dark:bg-[radial-gradient(125%_125%_at_50%_10%,#050505_40%,#243aff_100%)] bg-[radial-gradient(125%_125%_at_50%_10%,#ffffff_40%,#243aff_100%)]',
              overlayClassName
            )}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{
              duration: 0.2,
              ease: [0.4, 0.0, 0.4, 1],
            }}
            onClick={() => setIsOpen(false)}
          ></motion.div>
          <motion.div
            className={cn(`fixed inset-0 z-50 w-fit mx-auto`, className)}
            style={{ willChange: 'transform' }} // GPU acceleration for transforms
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>,
    document.body
  );
}
type DialogTitleProps = {
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
};

function DialogTitle({ children, className, style }: DialogTitleProps) {
  const { uniqueId } = useDialog();

  return (
    <motion.h1
      layoutId={`dialog-title-container-${uniqueId}`}
      className={className}
      style={style}
      layout
    >
      {children}
    </motion.h1>
  );
}

type DialogSubtitleProps = {
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
};

function DialogSubtitle({ children, className, style }: DialogSubtitleProps) {
  const { uniqueId } = useDialog();

  return (
    <motion.div
      layoutId={`dialog-subtitle-container-${uniqueId}`}
      className={className}
      style={style}
    >
      {children}
    </motion.div>
  );
}

type DialogDescriptionProps = {
  children: React.ReactNode;
  className?: string;
  disableLayoutAnimation?: boolean;
  variants?: {
    initial: Variant;
    animate: Variant;
    exit: Variant;
  };
};

function DialogDescription({
  children,
  className,
  variants,
  disableLayoutAnimation,
}: DialogDescriptionProps) {
  const { uniqueId } = useDialog();

  return (
    <motion.div
      key={`dialog-description-${uniqueId}`}
      layoutId={disableLayoutAnimation ? undefined : `dialog-description-content-${uniqueId}`}
      variants={variants}
      className={className}
      initial='initial'
      animate='animate'
      exit='exit'
      id={`dialog-description-${uniqueId}`}
    >
      {children}
    </motion.div>
  );
}

type DialogImageProps = {
  src: string;
  alt: string;
  className?: string;
  style?: React.CSSProperties;
};

function DialogImage({ src, alt, className, style }: DialogImageProps) {
  const { uniqueId } = useDialog();

  return (
    <motion.img
      src={src}
      alt={alt}
      className={cn(className)}
      layoutId={`dialog-img-${uniqueId}`}
      style={style}
    />
  );
}

type DialogCloseProps = {
  children?: React.ReactNode;
  className?: string;
  variants?: {
    initial: Variant;
    animate: Variant;
    exit: Variant;
  };
};

function DialogClose({ children, className, variants }: DialogCloseProps) {
  const { setIsOpen, uniqueId } = useDialog();

  const handleClose = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  return (
    <motion.button
      onClick={handleClose}
      type='button'
      aria-label='Close dialog'
      key={`dialog-close-${uniqueId}`}
      className={cn('absolute right-6 top-6 text-white', className)}
      initial='initial'
      animate='animate'
      exit='exit'
      variants={variants}
    >
      {children || <XIcon size={24} />}
    </motion.button>
  );
}

export {
  Dialog,
  DialogClose,
  DialogContainer,
  DialogContent,
  DialogDescription,
  DialogImage,
  DialogSubtitle,
  DialogTitle,
  DialogTrigger,
};

Componentes parecidos