Language : EN | KR
Grunfeld is a simple and intuitive dialog management library for React applications. You can implement modals, alerts, and confirmation dialogs with just a few lines of code, without complex state management.
- 🚀 Simple API – Ready to use without complex setup
- 🎯 Sync/Async Support – Handles everything from alerts to user input
- 📱 Flexible Positioning – Precisely place dialogs using a 9-grid system
- 🔄 Smart Stack Management – Logically manages dialogs in LIFO order
- ⚡ Top-layer Support – Utilizes native
<dialog>elements - 🎨 Fully Customizable – Freely style and control behavior
npm install grunfeld
# or
yarn add grunfeldAdd GrunfeldProvider at the top level of your app:
import { GrunfeldProvider } from "grunfeld";
function App() {
return <GrunfeldProvider>{/* app content */}</GrunfeldProvider>;
}import { grunfeld } from "grunfeld";
function MyComponent() {
const showAlert = () => {
// Simple alert - void return (no return value)
grunfeld.add(() => <div>Hello!</div>);
};
return <button onClick={showAlert}>Show Alert</button>;
}const showConfirm = async () => {
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>Are you sure you want to delete?</p>
<button onClick={() => removeWith(true)}>Confirm</button>
<button onClick={() => removeWith(false)}>Cancel</button>
</div>
),
}));
if (result) {
console.log("User clicked confirm");
} else {
console.log("User clicked cancel");
}
};If you omit the type parameter, it works as a simple alert that doesn't wait for a response:
// Basic alert – return a React element directly
grunfeld.add(() => (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
textAlign: "center",
}}
>
<p>Save completed!</p>
<button onClick={() => grunfeld.remove()}>OK</button>
</div>
));A confirmation dialog that waits for the user's choice:
const confirmed = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
textAlign: "center",
}}
>
<p>Are you sure you want to delete?</p>
<div>
<button onClick={() => removeWith(true)}>Delete</button>
<button onClick={() => removeWith(false)}>Cancel</button>
</div>
</div>
),
}));
if (confirmed) {
console.log("User confirmed deletion");
// Execute delete logic
} else {
console.log("User canceled");
}A dialog to receive input from the user:
const InputModal = ({ onClose }: { onClose: (name: string) => void }) => {
const [name, setName] = useState("");
return (
<div
style={{
padding: "20px",
background: "white",
borderRadius: "8px",
minWidth: "300px",
}}
>
<h2>Enter your name</h2>
<input
autoFocus
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && name.trim() && onClose(name.trim())
}
style={{ width: "100%", padding: "8px", marginBottom: "10px" }}
/>
<div>
<button
onClick={() => name.trim() && onClose(name.trim())}
disabled={!name.trim()}
style={{ marginRight: "10px" }}
>
Confirm
</button>
<button onClick={() => onClose("")}>Cancel</button>
</div>
</div>
);
};
export default function GrunfeldPage() {
return (
<button
onClick={async () => {
const value = await grunfeld.add<string>((removeWith) => ({
element: <InputModal onClose={removeWith} />,
}));
console.log(value);
}}
>
Test Button
</button>
);
}You can perform async operations when creating a dialog:
const result = await grunfeld.add<string>(async (removeWith) => {
// Show loading
const loadingElement = (
<div style={{ padding: "20px", textAlign: "center" }}>
<p>Loading user info...</p>
<div>⏳</div>
</div>
);
// Show loading dialog first
setTimeout(() => {
// After loading data, update content
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
// To update UI after successful load,
// create a new dialog or use state management
})
.catch(() => {
removeWith("Load failed");
});
}, 100);
return {
element: loadingElement,
};
});
// More practical example: selection list
const selectedItem = await grunfeld.add<string>(async (removeWith) => {
const items = await fetch("/api/items").then((res) => res.json());
return {
element: (
<div style={{ padding: "20px", minWidth: "250px" }}>
<h3>Select an item</h3>
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((item: any) => (
<li key={item.id}>
<button
onClick={() => removeWith(item.name)}
style={{
width: "100%",
padding: "8px",
marginBottom: "4px",
textAlign: "left",
}}
>
{item.name}
</button>
</li>
))}
</ul>
<button onClick={() => removeWith("")}>Cancel</button>
</div>
),
};
});<GrunfeldProvider
options={{
defaultPosition: "center", // Default position
defaultLightDismiss: true, // Dismiss on backdrop click
defaultRenderMode: "inline", // Render mode
defaultBackdropStyle: { // Default backdrop style
backgroundColor: "rgba(0, 0, 0, 0.5)"
}
}}
>grunfeld.add(() => ({
element: <MyDialog />,
position: "top-right", // Position (9-grid)
lightDismiss: false, // Disable backdrop click
renderMode: "top-layer", // Top-layer rendering
backdropStyle: {
// Custom backdrop
backgroundColor: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(5px)",
},
dismissCallback: () => {
// Function to run when dialog closes
console.log("Dialog closed");
},
}));
// Styling example
grunfeld.add(() => ({
element: (
<>
<h2>🎉 Congratulations!</h2>
<p>The operation was successful.</p>
<button onClick={() => grunfeld.remove()}>OK</button>
</>
),
position: "center",
backdropStyle: {
backgroundColor: "rgba(102, 126, 234, 0.1)",
backdropFilter: "blur(8px)",
},
}));You can place dialogs precisely using a 9-grid system:
top-left | top-center | top-right
center-left | center | center-right
bottom-left | bottom-center | bottom-right
Note: : The center position can be specified as either center or center-center.
Usage Example:
// Centered – both work the same
grunfeld.add(() => ({
element: <Modal />,
position: "center", // or "center-center"
}));
// Top-right notification
grunfeld.add(() => ({
element: <Notification />,
position: "top-right",
}));
// Bottom action sheet
grunfeld.add(() => ({
element: <ActionSheet />,
position: "bottom-center",
}));- Stable with z-index
- Supported by all browsers
- Flexible customization
- ESC key handled by JavaScript
- Uses native element
- No z-index conflicts
- Native ESC key handling by browser
- Supported only in modern browsers (Chrome 37+, Firefox 98+, Safari 15.4+)
grunfeld.add(() => ({
element: <MyDialog />,
renderMode: "top-layer", // Use native dialog
}));// Remove the most recent dialog
grunfeld.remove();
// Remove all dialogs
grunfeld.clear();
// Close with ESC key
// Or by clicking the backdrop if lightDismiss: trueDialogs are removed in LIFO (Last In First Out) order to maintain contextual relationships between dialogs.
If you forcibly close a dialog with grunfeld.remove() or grunfeld.clear(), the Promise for that dialog resolves to undefined:
// When a promise is in progress and the dialog is removed externally
const promise = grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>Are you sure?</p>
<button onClick={() => removeWith(true)}>Yes</button>
<button onClick={() => removeWith(false)}>No</button>
</div>
),
}));
// Remove the dialog from somewhere else
setTimeout(() => {
grunfeld.remove(); // promise resolves to undefined
}, 1000);
const result = await promise; // result is undefined
if (result === undefined) {
console.log("Dialog was interrupted");
} else if (result) {
console.log("User selected Yes");
} else {
console.log("User selected No");
}Practical Example:
const showConfirmWithTimeout = async () => {
const confirmPromise = grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>Please respond within 10 seconds. Do you confirm?</p>
<button onClick={() => removeWith(true)}>Confirm</button>
<button onClick={() => removeWith(false)}>Cancel</button>
</div>
),
}));
// Remove automatically after 10 seconds
const timeoutId = setTimeout(() => {
grunfeld.remove(); // Promise resolves to undefined
}, 10000);
const result = await confirmPromise;
clearTimeout(timeoutId); // Clear timer if user responds
if (result === undefined) {
console.log("Dialog closed due to timeout");
} else if (result) {
console.log("User selected Confirm");
} else {
console.log("User selected Cancel");
}
};This behavior prevents memory leaks and solves the hanging Promise problem. All Promises are properly cleaned up, so you can use them safely.
import React, { useState } from "react";
import { grunfeld, GrunfeldProvider } from "grunfeld";
function MyApp() {
const [message, setMessage] = useState("");
const showNotification = () => {
grunfeld.add(() => ({
element: <div>Notification displayed!</div>,
position: "top-right",
}));
// Remove automatically after 2 seconds
setTimeout(() => grunfeld.remove(), 2000);
};
const showConfirm = async () => {
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div
style={{ padding: "20px", background: "white", borderRadius: "8px" }}
>
<h3>Confirm</h3>
<p>Are you sure you want to proceed?</p>
<button onClick={() => removeWith(true)}>Yes</button>
<button onClick={() => removeWith(false)}>No</button>
</div>
),
}));
setMessage(result ? "Confirmed" : "Canceled");
};
const showInput = async () => {
const input = await grunfeld.add<string>((removeWith) => ({
element: <InputDialog onSubmit={removeWith} />,
}));
setMessage(input ? `Input: ${input}` : "Canceled");
};
return (
<GrunfeldProvider>
<div style={{ padding: "20px" }}>
<h1>Grunfeld Example</h1>
<button onClick={showNotification}>Show Notification</button>
<button onClick={showConfirm}>Confirmation Dialog</button>
<button onClick={showInput}>Input Dialog</button>
<p>Status: {message}</p>
</div>
</GrunfeldProvider>
);
}
const InputDialog = ({ onSubmit }: { onSubmit: (value: string) => void }) => {
const [value, setValue] = useState("");
return (
<div style={{ padding: "20px", background: "white", borderRadius: "8px" }}>
<h3>Input</h3>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter value"
autoFocus
/>
<div style={{ marginTop: "10px" }}>
<button onClick={() => onSubmit(value)}>Confirm</button>
<button onClick={() => onSubmit("")}>Cancel</button>
</div>
</div>
);
};Scenarios allow you to define and manage complex user flows step by step. Perfect for organizing multi-step processes like login, payment, onboarding, etc.
// Define login scenario
const loginScenario = grunfeld.scenario("login", {
showLoginForm: () => {
grunfeld.add(() => ({
element: <LoginForm />,
position: "center",
}));
},
showLoading: () => {
grunfeld.remove(); // Clean up previous step
grunfeld.add(() => ({
element: "Loading...",
position: "center",
}));
},
showSuccess: () => {
grunfeld.remove();
grunfeld.add(() => ({
element: "Login successful!",
position: "top-right",
}));
},
});
// Usage
await loginScenario.step("showLoginForm"); // Execute specific step
await loginScenario.run(); // Execute entire scenario// Define scenario that accepts parameters
const userScenario = grunfeld.scenario("user-flow", {
welcomeUser: ({ userName, userType }) => {
grunfeld.add(() => ({
element: `Welcome ${userName} (${userType})!`,
position: "center",
}));
},
showDashboard: ({ permissions = [] }) => {
grunfeld.add(() => ({
element: `Dashboard (permissions: ${permissions.join(", ")})`,
position: "center",
}));
},
});
// Pass parameters to individual steps
await userScenario.step("welcomeUser", {
userName: "John Doe",
userType: "Admin",
});
await userScenario.step("showDashboard", {
permissions: ["read", "write", "admin"],
});
// Pass parameters to entire scenario
await userScenario.run({
welcomeUser: { userName: "Jane Smith", userType: "User" },
showDashboard: { permissions: ["read"] },
});const registrationScenario = grunfeld.scenario("registration", {
getUserName: async () => {
const name = await grunfeld.add<string>((removeWith) => ({
element: (
<div>
<h3>Enter your name</h3>
<input
type="text"
onKeyPress={(e) => {
if (e.key === "Enter") {
removeWith(e.target.value);
}
}}
/>
</div>
),
position: "center",
}));
console.log("Received name:", name);
return name;
},
confirmData: async () => {
const confirmed = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>Is this information correct?</p>
<button onClick={() => removeWith(true)}>Confirm</button>
<button onClick={() => removeWith(false)}>Cancel</button>
</div>
),
position: "center",
}));
if (!confirmed) {
throw new Error("User cancelled");
}
},
});const advancedScenario = grunfeld.scenario(
"advanced",
{
step1: () => console.log("Step 1"),
step2: () => {
throw new Error("Error occurred");
},
step3: () => console.log("Step 3"),
},
{
stopOnError: false, // Continue even if errors occur
stepDelay: 1000, // 1 second delay between steps
onStepStart: (stepName) => console.log(`Starting: ${stepName}`),
onStepEnd: (stepName) => console.log(`Completed: ${stepName}`),
onStepError: (stepName, error) => console.log(`Error in: ${stepName}`),
}
);scenario.step(stepName, params?)- Execute specific step (with optional parameters)scenario.run(paramsMap?)- Execute all steps sequentially (with optional parameter map)scenario.getSteps()- Get available step namesscenario.hasStep(stepName)- Check if step existsscenario.clone(newName?)- Clone scenario
Two overloads:
- Simple alerts (no parameters):
grunfeld.add(() => React.ReactNode | GrunfeldProps): void- Executes immediately with no return value (synchronous)
- Get user response (with parameters):
grunfeld.add<T>((removeWith: (data: T) => void) => GrunfeldProps): Promise<T | undefined>- Waits for user response (asynchronous)
Usage Example:
// 1. Simple alert - no return value
grunfeld.add(() => <div>Simple alert</div>);
// With options
grunfeld.add(() => ({
element: <div>Alert with position</div>,
position: "top-right",
lightDismiss: false,
}));
// 2. Get user response - returns Promise
const result = await grunfeld.add<boolean>((removeWith) => ({
element: (
<div>
<p>Are you sure?</p>
<button onClick={() => removeWith(true)}>Yes</button>
<button onClick={() => removeWith(false)}>No</button>
</div>
),
}));
// Get string input
const input = await grunfeld.add<string>((removeWith) => ({
element: <InputForm onSubmit={removeWith} />,
})); <p>Are you sure?</p>
<button onClick={() => removeWith(true)}>Yes</button>
<button onClick={() => removeWith(false)}>No</button>
</div>
), }));
**GrunfeldProps:**
```typescript
{
element: React.ReactNode; // Content to display
position?: Position; // Position (default: "center")
lightDismiss?: boolean; // Dismiss on backdrop click (default: true)
backdropStyle?: React.CSSProperties; // Backdrop style
dismissCallback?: () => unknown; // Function to run when dialog closes (do NOT call grunfeld.remove() here)
renderMode?: "inline" | "top-layer"; // Render mode
}
dismissCallback runs when the dialog is removed, so do NOT call grunfeld.remove() or grunfeld.clear() inside it. To make an auto-dismissing alert, use setTimeout after creating the dialog:
// ❌ Wrong way
grunfeld.add(() => ({
element: <div>Alert</div>,
dismissCallback: () => {
setTimeout(() => grunfeld.remove(), 2000); // Risk of infinite loop
},
}));
// ✅ Correct way
grunfeld.add(() => ({
element: <div>Alert</div>,
}));
setTimeout(() => grunfeld.remove(), 2000);Removes the most recent dialog.
Removes all dialogs.
type PositionX = "left" | "center" | "right";
type PositionY = "top" | "center" | "bottom";
type Position = `${PositionY}-${PositionX}` | "center";Inline rendering: All modern browsers + IE 11+ Top-layer rendering: Chrome 37+, Firefox 98+, Safari 15.4+, Edge 79+