Skip to content

Overlays and Modals

import { ModalBase, InputField, ButtonBase } from "@lastshotlabs/snapshot/ui";
import { useState } from "react";
function CreateUserModal({ open, onClose, onSave }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
return (
<ModalBase
title="Create User"
open={open}
onClose={onClose}
footer={[
{ label: "Cancel", variant: "outline", onClick: onClose },
{
label: "Save",
variant: "default",
onClick: () => onSave({ name, email }),
},
]}
>
<InputField label="Name" value={name} onChange={setName} />
<InputField
label="Email"
type="email"
value={email}
onChange={setEmail}
/>
</ModalBase>
);
}

Full-screen centered dialog with backdrop, focus trap, and footer actions.

<ModalBase
title="Edit Project"
size="lg"
open={isOpen}
onClose={() => setIsOpen(false)}
footer={[
{ label: "Cancel", variant: "outline", onClick: () => setIsOpen(false) },
{ label: "Save", variant: "default", onClick: handleSave },
]}
footerAlign="right"
>
{/* form content */}
</ModalBase>

Sizes: sm, md, lg, xl, full

Props:

  • open / onClose — controlled visibility
  • title — header text
  • footer — array of { label, variant, onClick }
  • footerAlignleft | center | right
  • trapFocus — focus trap (default: true)
  • initialFocus — CSS selector for initial focus target
  • returnFocus — return focus on close (default: true)

Slide-in side panel. Same API as ModalBase but opens from the left or right.

import { DrawerBase } from "@lastshotlabs/snapshot/ui";
<DrawerBase
title="User Details"
side="right"
size="md"
open={isOpen}
onClose={() => setIsOpen(false)}
footer={[
{ label: "Close", variant: "outline", onClick: () => setIsOpen(false) },
]}
>
<DetailCardBase data={selectedUser} fields={userFields} />
</DrawerBase>;

Sides: left, right

Sizes: sm, md, lg, xl, full

Use the promise-based manager for application actions instead of window.confirm(), window.alert(), or window.prompt(). Mount one ConfirmDialog near the app root, then call confirm.show() where the action happens. The focused entrypoint needs React and Snapshot’s jotai optional peer, but does not load the full ./ui barrel.

Terminal window
npm install jotai
import {
ConfirmDialog,
useConfirmManager,
} from "@lastshotlabs/snapshot/ui/confirm";
function DeleteAccountButton() {
const confirm = useConfirmManager();
async function removeAccount() {
const accepted = await confirm.show({
title: "Delete account?",
description: "This permanently removes your account and its data.",
confirmLabel: "Delete account",
variant: "destructive",
requireInput: "DELETE",
});
if (!accepted) return;
await deleteAccount();
}
return <button onClick={() => void removeAccount()}>Delete account</button>;
}
export function AppChrome({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<ConfirmDialog />
</>
);
}

ConfirmDialog uses role="alertdialog", resolves the manager promise on both actions, and gives its buttons a minimum 46px height. For a product-owned palette, use slots instead of defining Snapshot tokens:

<ConfirmDialog
slots={{
overlay: {
style: {
background: "color-mix(in srgb, var(--ht-ink) 72%, transparent)",
},
},
dialog: {
style: {
background: "var(--ht-panel)",
color: "var(--ht-text)",
border: "1px solid var(--ht-border)",
borderRadius: "18px",
},
},
description: { style: { color: "var(--ht-muted)" } },
cancelButton: {
style: {
background: "var(--ht-secondary)",
color: "var(--ht-text)",
},
},
confirmButton: {
style: {
background: "var(--ht-danger)",
color: "var(--ht-danger-text)",
},
},
}}
/>

To keep native dialogs from creeping back in, add ESLint no-restricted-globals entries for confirm, alert, and prompt, plus no-restricted-properties entries for the same properties on window.

Simple confirmation dialog for destructive actions.

import { ConfirmDialogBase } from "@lastshotlabs/snapshot/ui";
<ConfirmDialogBase
title="Delete User"
description="This action cannot be undone. Are you sure you want to delete this user?"
open={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => {
deleteUser(userId);
setShowConfirm(false);
}}
confirmLabel="Delete"
confirmVariant="destructive"
cancelLabel="Cancel"
/>;

Spotlight-style search command palette (Ctrl+K / Cmd+K).

import { CommandPaletteBase } from "@lastshotlabs/snapshot/ui";
<CommandPaletteBase
open={isOpen}
onClose={() => setIsOpen(false)}
placeholder="Search commands..."
emptyMessage="No results found"
groups={[
{
label: "Navigation",
items: [
{ id: "dashboard", label: "Dashboard", icon: "home" },
{ id: "settings", label: "Settings", icon: "settings" },
{ id: "users", label: "Users", icon: "users" },
],
},
{
label: "Actions",
items: [
{ id: "create", label: "Create project", icon: "plus" },
{ id: "invite", label: "Invite member", icon: "user-plus" },
],
},
]}
onSelect={(item) => {
window.location.href = `/${item.id}`;
setIsOpen(false);
}}
/>;

For controlled search with async results:

<CommandPaletteBase
open={isOpen}
onClose={() => setIsOpen(false)}
query={searchQuery}
onQueryChange={setSearchQuery}
groups={filteredGroups}
onSelect={handleSelect}
/>

Dropdown menu triggered by a button click.

import { DropdownMenuBase } from "@lastshotlabs/snapshot/ui";
<DropdownMenuBase
trigger={{ label: "Actions", icon: "more-vertical", variant: "ghost" }}
items={[
{ type: "item", label: "Edit", icon: "edit" },
{ type: "item", label: "Duplicate", icon: "copy" },
{ type: "separator" },
{ type: "item", label: "Delete", icon: "trash", destructive: true },
]}
onSelect={(item) => handleAction(item.label)}
/>;

Item types: item (clickable), separator (divider line)

Right-click context menu.

import { ContextMenuBase } from "@lastshotlabs/snapshot/ui";
<ContextMenuBase
items={[
{ type: "item", label: "Cut", icon: "scissors" },
{ type: "item", label: "Copy", icon: "copy" },
{ type: "item", label: "Paste", icon: "clipboard" },
{ type: "separator" },
{ type: "item", label: "Delete", icon: "trash", destructive: true },
]}
onSelect={(item) => handleAction(item.label)}
>
<div style={{ padding: "2rem", border: "1px dashed gray" }}>
Right-click this area
</div>
</ContextMenuBase>;

Floating panel anchored to a trigger button.

import { PopoverBase } from "@lastshotlabs/snapshot/ui";
<PopoverBase
triggerLabel="Filter"
triggerIcon="filter"
triggerVariant="outline"
title="Filter Options"
placement="bottom"
width="300px"
>
<SelectField
label="Status"
options={statusOptions}
value={status}
onChange={setStatus}
/>
<SelectField
label="Priority"
options={priorityOptions}
value={priority}
onChange={setPriority}
/>
</PopoverBase>;

Floating card that opens on hover.

import { HoverCardBase } from "@lastshotlabs/snapshot/ui";
<HoverCardBase
trigger={<a href={`/users/${user.id}`}>{user.name}</a>}
side="bottom"
align="start"
width="300px"
openDelay={200}
closeDelay={100}
>
<AvatarBase src={user.avatar} name={user.name} size="lg" />
<p>{user.bio}</p>
</HoverCardBase>;

The standard CRUD pattern: open a modal from a table row action, edit fields, save to the server with loading and error handling.

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
function UserTable() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ items: User[] }>({
queryKey: ["/users"],
queryFn: () => snap.api.get("/users"),
});
const [editUser, setEditUser] = useState<User | null>(null);
const [draft, setDraft] = useState<User | null>(null);
const updateMutation = useMutation({
mutationFn: (user: User) => snap.api.patch(`/users/${user.id}`, user),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/users"] });
setEditUser(null);
setDraft(null);
},
});
// Sync draft when opening for edit
useEffect(() => {
if (editUser) setDraft({ ...editUser });
}, [editUser?.id]);
const active = draft ?? editUser;
return (
<>
<DataTableBase
columns={columns}
rows={data?.items ?? []}
isLoading={isLoading}
rowActions={[
{
label: "Edit",
icon: "edit",
onAction: (row) => setEditUser(row as User),
},
]}
/>
<ModalBase
title="Edit User"
open={editUser !== null}
onClose={() => {
setEditUser(null);
setDraft(null);
updateMutation.reset();
}}
footer={[
{
label: "Cancel",
variant: "outline",
onClick: () => {
setEditUser(null);
setDraft(null);
},
},
{
label: updateMutation.isPending ? "Saving..." : "Save",
onClick: () => {
if (active) updateMutation.mutate(active);
},
disabled: updateMutation.isPending,
},
]}
>
{active && (
<ColumnBase gap="md">
{updateMutation.error && (
<AlertBase severity="error">
{(updateMutation.error as Error).message}
</AlertBase>
)}
<InputField
label="Name"
value={active.name}
onChange={(v) => setDraft({ ...active, name: v })}
/>
<InputField
label="Email"
value={active.email}
onChange={(v) => setDraft({ ...active, email: v })}
/>
</ColumnBase>
)}
</ModalBase>
</>
);
}

ConfirmDialogBase auto-closes after the confirm button is clicked. Start the mutation in onConfirm and let the table refresh via query invalidation:

function DeleteButton({
userId,
userName,
onDeleted,
}: {
userId: string;
userName: string;
onDeleted: () => void;
}) {
const [showConfirm, setShowConfirm] = useState(false);
const deleteMutation = useMutation({
mutationFn: () => snap.api.delete(`/users/${userId}`),
onSuccess: onDeleted,
});
return (
<>
<ButtonBase
label="Delete"
variant="destructive"
onClick={() => setShowConfirm(true)}
/>
<ConfirmDialogBase
title="Delete User"
description={`Are you sure you want to delete ${userName}? This action cannot be undone.`}
open={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => deleteMutation.mutate()}
confirmLabel="Delete"
confirmVariant="destructive"
/>
{deleteMutation.error && (
<AlertBase severity="error">
{(deleteMutation.error as Error).message}
</AlertBase>
)}
</>
);
}
function CreateUserFlow() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const createMutation = useMutation({
mutationFn: (data: { name: string; email: string }) =>
snap.api.post("/users", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/users"] });
setOpen(false);
setName("");
setEmail("");
},
});
return (
<>
<ButtonBase label="Add User" icon="plus" onClick={() => setOpen(true)} />
<ModalBase
title="Create User"
open={open}
onClose={() => {
setOpen(false);
createMutation.reset();
}}
footer={[
{
label: "Cancel",
variant: "outline",
onClick: () => setOpen(false),
},
{
label: createMutation.isPending ? "Creating..." : "Create",
onClick: () => createMutation.mutate({ name, email }),
disabled: createMutation.isPending || !name || !email,
},
]}
>
<ColumnBase gap="md">
{createMutation.error && (
<AlertBase severity="error">
{(createMutation.error as Error).message}
</AlertBase>
)}
<InputField label="Name" value={name} onChange={setName} required />
<InputField
label="Email"
type="email"
value={email}
onChange={setEmail}
required
/>
</ColumnBase>
</ModalBase>
</>
);
}
ComponentDescription
ModalBaseCentered dialog with footer actions
DrawerBaseSlide-in side panel
ConfirmDialogBaseConfirmation dialog
CommandPaletteBaseSpotlight-style command search
DropdownMenuBaseButton-triggered dropdown menu
ContextMenuBaseRight-click context menu
PopoverBaseFloating panel with trigger
HoverCardBaseHover-activated floating card