Side Dialog
A lightweight dialog that opens from the sides of the screen, perfect for notifications, user menus, and contextual actions.
npx shadcn@latest add @shuip/side-dialog
pnpm dlx shadcn@latest add @shuip/side-dialog
bun x shadcn@latest add @shuip/side-dialog
'use client';
import { Button as ButtonPrimitive } from '@base-ui/react/button';
import { XIcon } from 'lucide-react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
type SideDialogPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'left' | 'right';
type SideDialogSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
export interface SideDialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
position?: SideDialogPosition;
size?: SideDialogSize;
children?: React.ReactNode;
}
export interface SideDialogTriggerProps extends ButtonPrimitive.Props {}
export interface SideDialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
showCloseButton?: boolean;
children: React.ReactNode;
}
export interface SideDialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export interface SideDialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
}
export interface SideDialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
export interface SideDialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export interface SideDialogCloseProps extends ButtonPrimitive.Props {}
type ButtonClickEvent = Parameters<NonNullable<ButtonPrimitive.Props['onClick']>>[0];
export interface SideDialogBodyProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
// Context for managing dialog state
const SideDialogContext = React.createContext<{
open: boolean;
onOpenChange: (open: boolean) => void;
position: SideDialogPosition;
size: SideDialogSize;
openerRef: React.RefObject<HTMLElement | null>;
}>({
open: false,
onOpenChange: () => {},
position: 'bottom-right',
size: 'sm',
openerRef: { current: null },
});
const getFocusedElement = () => {
const { activeElement } = document;
return activeElement instanceof HTMLElement &&
activeElement !== document.body &&
activeElement !== document.documentElement
? activeElement
: null;
};
export function SideDialog({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
position = 'bottom-right',
size = 'sm',
children,
}: SideDialogProps) {
// Internal state for uncontrolled mode
const [internalOpen, setInternalOpen] = React.useState(false);
// Use controlled props if provided, otherwise use internal state
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const onOpenChange = controlledOnOpenChange !== undefined ? controlledOnOpenChange : setInternalOpen;
// The element to give focus back to on close. An insertion effect runs before the layout phase, where the content's
// `autoFocus` fields take focus, so it still sees the focused element of the opener (a layout effect would not).
const openerRef = React.useRef<HTMLElement | null>(null);
React.useInsertionEffect(() => {
if (open) openerRef.current = getFocusedElement();
}, [open]);
const contextValue = React.useMemo(
() => ({
open,
onOpenChange,
position,
size,
openerRef,
}),
[open, onOpenChange, position, size],
);
return <SideDialogContext.Provider value={contextValue}>{children}</SideDialogContext.Provider>;
}
export function SideDialogTrigger({ onClick, ...props }: SideDialogTriggerProps) {
const { onOpenChange } = React.useContext(SideDialogContext);
const handleClick = React.useCallback(
(e: ButtonClickEvent) => {
onClick?.(e);
e.currentTarget.focus();
onOpenChange(true);
},
[onClick, onOpenChange],
);
return <ButtonPrimitive data-slot='side-dialog-trigger' onClick={handleClick} {...props} />;
}
export function SideDialogContent({ showCloseButton = true, className, children, ...props }: SideDialogContentProps) {
const { open, onOpenChange, position, size, openerRef } = React.useContext(SideDialogContext);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Runs once the dialog is gone, on close and when the content unmounts while open. Focus on `body` then means
// nothing else took it, so it goes back to the opener; a field focused by an `onOpenChange(false)` handler stays.
React.useEffect(() => {
if (!open) return;
return () => {
const opener = openerRef.current;
if (opener?.isConnected && getFocusedElement() === null) {
opener.focus();
}
};
}, [open, openerRef]);
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onOpenChange(false);
}
};
if (open) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [open, onOpenChange]);
const handleOverlayClick = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onOpenChange(false);
}
},
[onOpenChange],
);
const getSizeClasses = (size: SideDialogSize) => {
const predefinedSizes = {
xs: 'w-full max-w-[min(20rem,calc(100vw-2rem))]',
sm: 'w-full max-w-[min(24rem,calc(100vw-2rem))]',
md: 'w-full max-w-[min(28rem,calc(100vw-2rem))]',
lg: 'w-full max-w-[min(32rem,calc(100vw-2rem))]',
xl: 'w-full max-w-[min(36rem,calc(100vw-2rem))]',
'2xl': 'w-full max-w-[min(42rem,calc(100vw-2rem))]',
};
return predefinedSizes[size as keyof typeof predefinedSizes] || 'w-full max-w-[min(24rem,calc(100vw-2rem))]';
};
const getPositionClasses = (pos: SideDialogPosition, size: SideDialogSize) => {
const baseClasses = 'fixed z-[100] bg-background border rounded-lg shadow-lg';
const sizeClasses = getSizeClasses(size);
switch (pos) {
case 'top-left':
return cn(
baseClasses,
sizeClasses,
'top-4 left-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-top-52 data-[state=closed]:slide-out-to-left-52',
'data-[state=open]:slide-in-from-top-52 data-[state=open]:slide-in-from-left-52',
);
case 'top-right':
return cn(
baseClasses,
sizeClasses,
'top-4 right-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-top-52 data-[state=closed]:slide-out-to-right-52',
'data-[state=open]:slide-in-from-top-52 data-[state=open]:slide-in-from-right-52',
);
case 'bottom-left':
return cn(
baseClasses,
sizeClasses,
'bottom-4 left-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-bottom-52 data-[state=closed]:slide-out-to-left-52',
'data-[state=open]:slide-in-from-bottom-52 data-[state=open]:slide-in-from-left-52',
);
case 'bottom-right':
return cn(
baseClasses,
sizeClasses,
'bottom-4 right-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-bottom-52 data-[state=closed]:slide-out-to-right-52',
'data-[state=open]:slide-in-from-bottom-52 data-[state=open]:slide-in-from-right-52',
);
case 'left':
return cn(
baseClasses,
sizeClasses,
'left-4 top-[50%] translate-y-[-50%]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-left-52',
'data-[state=open]:slide-in-from-left-52',
);
case 'right':
return cn(
baseClasses,
sizeClasses,
'right-4 top-[50%] translate-y-[-50%]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-right-52',
'data-[state=open]:slide-in-from-right-52',
);
default:
return cn(baseClasses, sizeClasses);
}
};
if (!mounted) return null;
if (!open) return null;
return createPortal(
<div
data-slot='side-dialog-overlay'
className='fixed inset-0 z-40 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
data-state={open ? 'open' : 'closed'}
onClick={handleOverlayClick}
>
<div
data-slot='side-dialog-content'
data-state={open ? 'open' : 'closed'}
className={cn(
getPositionClasses(position, size),
'flex flex-col max-h-[calc(100dvh-2rem)] p-6 duration-200',
className,
)}
{...props}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
>
{children}
{showCloseButton && (
<SideDialogClose className='absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden'>
<XIcon className='size-4' />
<span className='sr-only'>Close</span>
</SideDialogClose>
)}
</div>
</div>,
document.body,
);
}
export function SideDialogHeader({ className, children, ...props }: SideDialogHeaderProps) {
return (
<div data-slot='side-dialog-header' className={cn('shrink-0 flex flex-col gap-2 mb-4', className)} {...props}>
{children}
</div>
);
}
export function SideDialogTitle({ className, children, ...props }: SideDialogTitleProps) {
return (
<h2 data-slot='side-dialog-title' className={cn('text-lg font-semibold leading-none', className)} {...props}>
{children}
</h2>
);
}
export function SideDialogDescription({ className, children, ...props }: SideDialogDescriptionProps) {
return (
<p data-slot='side-dialog-description' className={cn('text-muted-foreground text-sm', className)} {...props}>
{children}
</p>
);
}
export function SideDialogFooter({ className, children, ...props }: SideDialogFooterProps) {
return (
<div
data-slot='side-dialog-footer'
className={cn('shrink-0 flex flex-col-reverse gap-2 mt-4 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
</div>
);
}
export function SideDialogBody({ className, children, ...props }: SideDialogBodyProps) {
return (
<div data-slot='side-dialog-body' className={cn('flex-1 min-h-0 overflow-y-auto', className)} {...props}>
{children}
</div>
);
}
export function SideDialogClose({ onClick, ...props }: SideDialogCloseProps) {
const { onOpenChange } = React.useContext(SideDialogContext);
const handleClick = React.useCallback(
(e: ButtonClickEvent) => {
onClick?.(e);
onOpenChange(false);
},
[onClick, onOpenChange],
);
return <ButtonPrimitive data-slot='side-dialog-close' onClick={handleClick} {...props} />;
}
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
Contextual positioning
Unlike traditional centered dialogs, SideDialog provides a more contextual user experience by opening from the edges of the screen, perfect for notifications, user menus, and contextual actions.
Built-in features
- 6 positions: Choose from
top-left,top-right,bottom-left,bottom-right,left,right - Flexible sizes: Predefined sizes (
xs,sm,md,lg,xl,2xl) - Scrollable body:
SideDialogBodykeeps the header and footer anchored while the content scrolls - Lightweight: Custom implementation without depending on shadcn dialog
- Accessible: Keyboard support (Escape), focus management, and ARIA compliance
- Portal rendering: Renders in
document.bodyto avoid z-index issues - Smooth animations: Entry/exit animations with Tailwind CSS
- Controlled & Uncontrolled: Works with or without state management
Position options
The position prop controls where the dialog appears:
// Corner positions
<SideDialog position="top-left"> {/* Top left corner */}
<SideDialog position="top-right"> {/* Top right corner */}
<SideDialog position="bottom-left"> {/* Bottom left corner */}
<SideDialog position="bottom-right"> {/* Bottom right corner (default) */}
// Side positions (vertically centered)
<SideDialog position="left"> {/* Left side, centered */}
<SideDialog position="right"> {/* Right side, centered */}Size options
The size prop controls the dialog size with predefined responsive values:
// Predefined sizes (all automatically responsive)
<SideDialog size="xs"> {/* 320px max-width, adapts to small screens */}
<SideDialog size="sm"> {/* 384px max-width (default) */}
<SideDialog size="md"> {/* 448px max-width */}
<SideDialog size="lg"> {/* 512px max-width */}
<SideDialog size="xl"> {/* 576px max-width */}
<SideDialog size="2xl"> {/* 672px max-width */}
// For custom sizes, use className with responsive constraints
<SideDialogContent className="w-full max-w-[min(400px,calc(100vw-2rem))]">
{/* Custom size with responsive protection */}
</SideDialogContent>Responsive behavior
All predefined sizes automatically include responsive constraints using CSS min() function:
- Desktop: Uses the specified max-width (e.g.
lg= 32rem/512px) - Small screens: Automatically reduces to
calc(100vw - 2rem)when viewport is smaller - Never overflows: The dialog will always fit within the viewport with 1rem margin on each side
- CSS native: Uses
min(targetSize, calc(100vw - 2rem))for optimal performance
Scrollable content
Use SideDialogBody when the dialog content may overflow. The header and footer stay anchored while the body scrolls independently.
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Activity Log</SideDialogTitle>
<SideDialogDescription>Recent actions across your workspace.</SideDialogDescription>
</SideDialogHeader>
<SideDialogBody>
{/* long list or form content */}
</SideDialogBody>
<SideDialogFooter>
<SideDialogClose render={<Button variant="outline" />}>Close</SideDialogClose>
</SideDialogFooter>
</SideDialogContent>Without SideDialogBody, content simply stacks inside the dialog — fine for short dialogs. Add it as soon as the content might exceed the viewport height.
Common use cases
Notifications and alerts
// Success notification - top right
<SideDialog position="top-right">
<SideDialogTrigger render={<Button onClick={handleSave} />}>Save Changes</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle className="text-green-600">Success!</SideDialogTitle>
<SideDialogDescription>Your changes have been saved.</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
// Error notification - top left
<SideDialog position="top-left">
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle className="text-red-600">Error</SideDialogTitle>
<SideDialogDescription>Something went wrong. Please try again.</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>Different sizes for different content
// Small notification - xs size
<SideDialog position="top-right" size="xs">
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Saved!</SideDialogTitle>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
// User profile - medium size
<SideDialog position="top-right" size="md">
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>User Profile</SideDialogTitle>
</SideDialogHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<Avatar className="w-16 h-16">
<AvatarImage src="/avatar.jpg" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold">John Doe</h3>
<p className="text-muted-foreground">john@example.com</p>
</div>
</div>
</div>
</SideDialogContent>
</SideDialog>
// Wide content panel - custom size with responsive constraints
<SideDialog position="right">
<SideDialogContent className="w-full max-w-[min(600px,calc(100vw-2rem))]">
<SideDialogHeader>
<SideDialogTitle>Content Editor</SideDialogTitle>
</SideDialogHeader>
<div className="space-y-4">
<Textarea placeholder="Write your content here..." rows={10} />
<div className="flex justify-end space-x-2">
<Button variant="outline">Save Draft</Button>
<Button>Publish</Button>
</div>
</div>
</SideDialogContent>
</SideDialog>Help and information
// Help panel - bottom left
<SideDialog position="bottom-left">
<SideDialogTrigger render={<Button variant="outline" size="icon" />}>
<HelpCircle className="size-4" />
</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Need Help?</SideDialogTitle>
<SideDialogDescription>
Find answers to common questions or contact support.
</SideDialogDescription>
</SideDialogHeader>
<div className="space-y-3">
<Button variant="outline" className="w-full">
<Book className="mr-2 size-4" />
Documentation
</Button>
<Button variant="outline" className="w-full">
<MessageCircle className="mr-2 size-4" />
Contact Support
</Button>
</div>
</SideDialogContent>
</SideDialog>State management
The component supports both controlled and uncontrolled modes, automatically detecting which mode to use based on the props provided:
// Uncontrolled: Component manages its own state
<SideDialog position="top-right">
<SideDialogTrigger render={<Button />}>Open</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Self-managed State</SideDialogTitle>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
// Controlled: You manage the state
const [open, setOpen] = useState(false);
<SideDialog open={open} onOpenChange={setOpen} position="top-right">
<SideDialogTrigger render={<Button />}>Open</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Controlled State</SideDialogTitle>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>Advanced customization
Custom close behavior
// Disable the default close button
<SideDialogContent showCloseButton={false}>
<SideDialogHeader>
<SideDialogTitle>Custom Controls</SideDialogTitle>
</SideDialogHeader>
<SideDialogFooter>
<SideDialogClose render={<Button variant="destructive" />}>Cancel</SideDialogClose>
<SideDialogClose render={<Button />}>Confirm</SideDialogClose>
</SideDialogFooter>
</SideDialogContent>Custom styling
// Custom dialog appearance
<SideDialogContent className="border-primary shadow-xl bg-gradient-to-b from-background to-muted">
<SideDialogHeader>
<SideDialogTitle className="text-primary">Custom Style</SideDialogTitle>
</SideDialogHeader>
</SideDialogContent>Examples
Default
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
SideDialog,
SideDialogClose,
SideDialogContent,
SideDialogDescription,
SideDialogFooter,
SideDialogHeader,
SideDialogTitle,
SideDialogTrigger,
} from '@/components/ui/shuip/side-dialog';
export default function SideDialogExample() {
const [open, setOpen] = useState(false);
return (
<div className='flex flex-wrap gap-4 p-8'>
<SideDialog open={open} onOpenChange={setOpen}>
<SideDialogTrigger render={<Button variant='outline' />}>Open Dialog (Bottom Right)</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Side Dialog Example</SideDialogTitle>
<SideDialogDescription>
This is a side dialog that opens on the bottom right by default. On mobile devices, it automatically
becomes a drawer.
</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm text-muted-foreground'>
Content goes here. This dialog is responsive and will adapt to mobile devices by showing as a drawer
instead.
</p>
</div>
<SideDialogFooter>
<SideDialogClose render={<Button variant='outline' />}>Cancel</SideDialogClose>
<Button>Save changes</Button>
</SideDialogFooter>
</SideDialogContent>
</SideDialog>
<SideDialog position='top-left'>
<SideDialogTrigger render={<Button variant='outline' />}>Top Left</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Top Left Dialog</SideDialogTitle>
<SideDialogDescription>This dialog opens from the top left corner.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>Perfect for notifications or quick actions.</p>
</div>
</SideDialogContent>
</SideDialog>
<SideDialog position='top-right'>
<SideDialogTrigger render={<Button variant='outline' />}>Top Right</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Top Right Dialog</SideDialogTitle>
<SideDialogDescription>This dialog opens from the top right corner.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>Great for user menus or profile settings.</p>
</div>
</SideDialogContent>
</SideDialog>
<SideDialog position='bottom-left'>
<SideDialogTrigger render={<Button variant='outline' />}>Bottom Left</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Bottom Left Dialog</SideDialogTitle>
<SideDialogDescription>This dialog opens from the bottom left corner.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>Ideal for help or support content.</p>
</div>
</SideDialogContent>
</SideDialog>
<SideDialog position='left'>
<SideDialogTrigger render={<Button variant='outline' />}>Left Side</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Left Side Dialog</SideDialogTitle>
<SideDialogDescription>This dialog opens from the left side, centered vertically.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>Perfect for navigation or filters.</p>
</div>
</SideDialogContent>
</SideDialog>
<SideDialog position='right'>
<SideDialogTrigger render={<Button variant='outline' />}>Right Side</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Right Side Dialog</SideDialogTitle>
<SideDialogDescription>This dialog opens from the right side, centered vertically.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>Great for sidebars or additional information.</p>
</div>
<SideDialogFooter>
<SideDialogClose render={<Button variant='outline' />}>Close</SideDialogClose>
</SideDialogFooter>
</SideDialogContent>
</SideDialog>
<SideDialog>
<SideDialogTrigger render={<Button variant='outline' />}>No Close Button</SideDialogTrigger>
<SideDialogContent showCloseButton={false}>
<SideDialogHeader>
<SideDialogTitle>Custom Close</SideDialogTitle>
<SideDialogDescription>This dialog doesn't show the default close button.</SideDialogDescription>
</SideDialogHeader>
<div className='py-4'>
<p className='text-sm'>You can control closing with custom buttons only.</p>
</div>
<SideDialogFooter>
<SideDialogClose render={<Button />}>Done</SideDialogClose>
</SideDialogFooter>
</SideDialogContent>
</SideDialog>
{/* Different Sizes */}
<SideDialog size='xs'>
<SideDialogTrigger render={<Button variant='outline' />}>Extra Small (xs)</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>XS Size</SideDialogTitle>
<SideDialogDescription>Extra small dialog size (320px max).</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog size='md' position='top-left'>
<SideDialogTrigger render={<Button variant='outline' />}>Medium (md)</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>MD Size</SideDialogTitle>
<SideDialogDescription>Medium dialog size (448px max).</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog size='xl' position='left'>
<SideDialogTrigger render={<Button variant='outline' />}>Extra Large (xl)</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>XL Size</SideDialogTitle>
<SideDialogDescription>Extra large dialog size (576px max).</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog size='2xl' position='bottom-left'>
<SideDialogTrigger render={<Button variant='outline' />}>2XL Size</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>2XL Size</SideDialogTitle>
<SideDialogDescription>Extra extra large dialog size (672px max).</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog position='right'>
<SideDialogTrigger render={<Button variant='outline' />}>Custom 400px</SideDialogTrigger>
<SideDialogContent className='w-full max-w-[min(400px,calc(100vw-2rem))]'>
<SideDialogHeader>
<SideDialogTitle>Custom Size</SideDialogTitle>
<SideDialogDescription>Custom size of 400px with responsive constraints.</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog position='bottom-left'>
<SideDialogTrigger render={<Button variant='outline' />}>50% Viewport</SideDialogTrigger>
<SideDialogContent className='w-full max-w-[min(50vw,calc(100vw-2rem))]'>
<SideDialogHeader>
<SideDialogTitle>Viewport Size</SideDialogTitle>
<SideDialogDescription>50% of viewport width with responsive constraints.</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
<SideDialog>
<SideDialogTrigger
render={<Button variant='outline' className='bg-gradient-to-r from-blue-300 to-purple-300' />}
>
Colored
</SideDialogTrigger>
<SideDialogContent className='bg-gradient-to-r from-blue-300 to-purple-300'>
<SideDialogHeader>
<SideDialogTitle>Colored</SideDialogTitle>
<SideDialogDescription>Colored dialog.</SideDialogDescription>
</SideDialogHeader>
</SideDialogContent>
</SideDialog>
</div>
);
}
Form
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { InputField } from '@/components/ui/shuip/react-hook-form/input-field';
import {
SideDialog,
SideDialogBody,
SideDialogClose,
SideDialogContent,
SideDialogDescription,
SideDialogFooter,
SideDialogHeader,
SideDialogTitle,
SideDialogTrigger,
} from '@/components/ui/shuip/side-dialog';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
});
export default function SideDialogSimpleExample() {
const [open, setOpen] = useState(false);
const form = useForm({
defaultValues: { name: '' },
resolver: zodResolver(formSchema),
});
const lens = useLens({ control: form.control });
const onSubmit = async (data: z.infer<typeof formSchema>) => {
await new Promise((resolve) => setTimeout(resolve, 1500));
alert(`Form submitted: ${JSON.stringify(data)}`);
setOpen(false);
form.reset();
};
return (
<SideDialog open={open} onOpenChange={setOpen}>
<SideDialogTrigger render={<Button variant='outline' />}>Open Form Side Dialog</SideDialogTrigger>
<SideDialogContent>
<form onSubmit={form.handleSubmit(onSubmit)}>
<SideDialogHeader>
<SideDialogTitle>Form Side Dialog</SideDialogTitle>
<SideDialogDescription>This is a form side dialog.</SideDialogDescription>
</SideDialogHeader>
<SideDialogBody className='space-y-4'>
<InputField lens={lens.focus('name')} label='Name' description='This is a description' />
</SideDialogBody>
<SideDialogFooter>
<SideDialogClose render={<Button variant='outline' />}>Cancel</SideDialogClose>
<SubmitButton className='w-fit'>Submit</SubmitButton>
</SideDialogFooter>
</form>
</SideDialogContent>
</SideDialog>
);
}
Scrollable
'use client';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
SideDialog,
SideDialogBody,
SideDialogClose,
SideDialogContent,
SideDialogDescription,
SideDialogFooter,
SideDialogHeader,
SideDialogTitle,
SideDialogTrigger,
} from '@/components/ui/shuip/side-dialog';
const ACTIVITY_LOG = [
{ id: 1, user: 'Alice Martin', action: 'Updated billing address', time: '2 minutes ago', type: 'edit' },
{ id: 2, user: 'Bob Chen', action: 'Exported 847 records to CSV', time: '14 minutes ago', type: 'export' },
{ id: 3, user: 'Alice Martin', action: 'Invited carol@example.com', time: '1 hour ago', type: 'invite' },
{ id: 4, user: 'System', action: 'Scheduled backup completed', time: '2 hours ago', type: 'system' },
{ id: 5, user: 'Dave Wilson', action: 'Deleted workspace "Legacy"', time: '3 hours ago', type: 'delete' },
{ id: 6, user: 'Alice Martin', action: 'Changed plan to Pro', time: '5 hours ago', type: 'edit' },
{ id: 7, user: 'Bob Chen', action: 'Created API key "Production"', time: '6 hours ago', type: 'create' },
{ id: 8, user: 'System', action: 'SSL certificate renewed', time: 'Yesterday at 11:42 PM', type: 'system' },
{ id: 9, user: 'Dave Wilson', action: 'Added 3 new team members', time: 'Yesterday at 4:12 PM', type: 'invite' },
{ id: 10, user: 'Carol Lee', action: 'Reset 2FA for bob@example.com', time: 'Yesterday at 2:05 PM', type: 'edit' },
{ id: 11, user: 'Alice Martin', action: 'Enabled SSO for domain acme.com', time: '2 days ago', type: 'edit' },
{ id: 12, user: 'System', action: 'Monthly invoice generated — $429.00', time: '3 days ago', type: 'system' },
{ id: 13, user: 'Bob Chen', action: 'Revoked API key "Staging"', time: '3 days ago', type: 'delete' },
{ id: 14, user: 'Carol Lee', action: 'Updated webhook endpoint', time: '4 days ago', type: 'edit' },
{ id: 15, user: 'Dave Wilson', action: 'Archived project "Q3 Campaign"', time: '5 days ago', type: 'edit' },
];
const TYPE_COLORS: Record<string, string> = {
edit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
export: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
invite: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
system: 'bg-muted text-muted-foreground',
delete: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
create: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
};
export default function SideDialogScrollableExample() {
return (
<div className='flex flex-wrap gap-3'>
<SideDialog position='bottom-right' size='md'>
<SideDialogTrigger render={<Button variant='outline' />}>Activity Log</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Activity Log</SideDialogTitle>
<SideDialogDescription>Recent actions across your workspace.</SideDialogDescription>
</SideDialogHeader>
<SideDialogBody className='space-y-1'>
{ACTIVITY_LOG.map((entry, i) => (
<div key={entry.id}>
<div className='flex items-start gap-3 py-2.5'>
<span
className={`mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-xs font-medium capitalize ${TYPE_COLORS[entry.type]}`}
>
{entry.type}
</span>
<div className='min-w-0 flex-1'>
<p className='text-sm leading-snug'>{entry.action}</p>
<p className='text-muted-foreground mt-0.5 text-xs'>
{entry.user} · {entry.time}
</p>
</div>
</div>
{i < ACTIVITY_LOG.length - 1 && <Separator />}
</div>
))}
</SideDialogBody>
<SideDialogFooter>
<SideDialogClose render={<Button variant='outline' className='w-full' />}>Close</SideDialogClose>
</SideDialogFooter>
</SideDialogContent>
</SideDialog>
<SideDialog position='right' size='sm'>
<SideDialogTrigger render={<Button variant='outline' />}>Notifications</SideDialogTrigger>
<SideDialogContent>
<SideDialogHeader>
<SideDialogTitle>Notifications</SideDialogTitle>
<SideDialogDescription>You have 8 unread notifications.</SideDialogDescription>
</SideDialogHeader>
<SideDialogBody className='space-y-3'>
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className='flex items-start gap-3 rounded-md border p-3'>
<div className={`mt-1 size-2 shrink-0 rounded-full ${i < 8 ? 'bg-blue-500' : 'bg-transparent'}`} />
<div className='min-w-0 flex-1'>
<p className='text-sm font-medium leading-snug'>
{
[
'New comment on your post',
'Alice mentioned you in a thread',
'Your export is ready to download',
'Dave accepted your invitation',
'Payment processed successfully',
'Your API key is expiring soon',
'New login from Chrome on macOS',
'Weekly report is available',
'Bob replied to your comment',
'Scheduled maintenance tonight',
'Your trial ends in 3 days',
'New feature: AI summaries',
][i]
}
</p>
<p className='text-muted-foreground mt-0.5 text-xs'>{i === 0 ? 'Just now' : `${i * 2 + 1}h ago`}</p>
</div>
</div>
))}
</SideDialogBody>
<SideDialogFooter>
<Button variant='ghost' size='sm' className='w-full'>
Mark all as read
</Button>
</SideDialogFooter>
</SideDialogContent>
</SideDialog>
</div>
);
}
Props
Prop
Type
SideDialogContent Props
Prop
Type
SideDialogBody Props
Prop
Type
SideDialogTrigger Props
Prop
Type