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> );}ModalBase
Section titled “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 visibilitytitle— header textfooter— array of{ label, variant, onClick }footerAlign—left|center|righttrapFocus— focus trap (default:true)initialFocus— CSS selector for initial focus targetreturnFocus— return focus on close (default:true)
DrawerBase
Section titled “DrawerBase”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
Managed confirmations
Section titled “Managed confirmations”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.
npm install jotaiimport { 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.
ConfirmDialogBase
Section titled “ConfirmDialogBase”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"/>;CommandPaletteBase
Section titled “CommandPaletteBase”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}/>DropdownMenuBase
Section titled “DropdownMenuBase”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)
ContextMenuBase
Section titled “ContextMenuBase”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>;PopoverBase
Section titled “PopoverBase”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>;HoverCardBase
Section titled “HoverCardBase”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>;Composition patterns
Section titled “Composition patterns”Modal form with async save
Section titled “Modal form with async save”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> </> );}Confirm before delete
Section titled “Confirm before delete”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> )} </> );}Create form in a modal
Section titled “Create form in a modal”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> </> );}All overlay components
Section titled “All overlay components”| Component | Description |
|---|---|
ModalBase | Centered dialog with footer actions |
DrawerBase | Slide-in side panel |
ConfirmDialogBase | Confirmation dialog |
CommandPaletteBase | Spotlight-style command search |
DropdownMenuBase | Button-triggered dropdown menu |
ContextMenuBase | Right-click context menu |
PopoverBase | Floating panel with trigger |
HoverCardBase | Hover-activated floating card |
Next steps
Section titled “Next steps”- Layout and Navigation — app shells and sidebars
- Forms and Validation — form components to use inside modals
- Theming and Styling — customize overlay appearance