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.
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
'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;
'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,
};