Dialog
14 blocksDialog 01
Preview
npx nateui@latest add DialogPaymentSuccessDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import EmptyState from '@/components/composites/EmptyState'
import { PiCheckBold } from 'react-icons/pi'
const dialogTitleId = 'dialog-payment-success-title'
const dialogDescriptionId = 'dialog-payment-success-description'
export default function DialogPaymentSuccess() {
const [isOpen, setIsOpen] = useState(true)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
className="w-full p-0"
closable={false}
isOpen={isOpen}
lockScroll
width={380}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col items-center px-8 pb-8 pt-2 text-center">
<EmptyState
illustration={
<div className="rounded-full bg-palette-emerald-soft p-2">
<div
className="flex items-center justify-center rounded-full bg-palette-emerald text-white size-15"
>
<PiCheckBold
aria-hidden="true"
className="text-3xl"
/>
</div>
</div>
}
size={220}
offset={-40}
>
<h5
id={dialogTitleId}
className="font-semibold text-lg"
>
Payment complete
</h5>
<p
className="mt-1 text-muted-foreground"
id={dialogDescriptionId}
>
A receipt for this charge is ready to open.
</p>
</EmptyState>
<div className="mt-8 grid w-full grid-cols-1 gap-2">
<Button
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
View receipt
</Button>
<Button
type="button"
onClick={() => setIsOpen(false)}
>
Back to home
</Button>
</div>
</div>
</Dialog>
</main>
)
}
Dialog 02
Preview
npx nateui@latest add DialogPolicyNoticeDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import Link from '@/components/ui/Link'
import { PiFileText } from 'react-icons/pi'
const dialogTitleId = 'dialog-policy-notice-title'
const dialogDescriptionId = 'dialog-policy-notice-description'
export default function DialogPolicyNotice() {
const [isOpen, setIsOpen] = useState(true)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
isOpen={isOpen}
lockScroll
width={460}
onClose={() => setIsOpen(false)}
>
<PiFileText
aria-hidden="true"
className="text-2xl text-muted-foreground"
/>
<h5
className="mt-4 text-lg font-semibold"
id={dialogTitleId}
>
Terms of service update
</h5>
<p
className="mt-1 text-muted-foreground"
id={dialogDescriptionId}
>
The terms that govern access to this service have
changed. Review them before you continue, since they may
affect what you're able to do here.
</p>
<div className="mt-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
onClick={() => setIsOpen(false)}
>
Dismiss
</Button>
<Button
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Open terms
</Button>
</div>
</div>
</Dialog>
</main>
)
}
Dialog 03
Preview
npx nateui@latest add DialogDateTimePickerDark
import { useState } from 'react'
import Calendar from '@/components/ui/Calendar'
import Dialog from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Menu from '@/components/ui/Menu'
import TimeInput from '@/components/ui/TimeInput'
import Divider from '@/components/composites/Divider'
import IconFrame from '@/components/composites/IconFrame'
import useResponsive from '@/hooks/useResponsive'
import { PiClock } from 'react-icons/pi'
const dialogDateTimePickerTitleId = 'dialog-date-time-picker-title'
const dialogDateTimePickerDescriptionId = 'dialog-date-time-picker-description'
const dateFromIso = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
const dateWithTime = (value: string, hours: number, minutes: number) => {
const date = dateFromIso(value)
date.setHours(hours, minutes, 0, 0)
return date
}
const presetRanges = {
Today: [dateFromIso('2026-08-15'), dateFromIso('2026-08-15')],
Tomorrow: [dateFromIso('2026-08-16'), dateFromIso('2026-08-16')],
'This week': [dateFromIso('2026-08-10'), dateFromIso('2026-08-16')],
'Next week': [dateFromIso('2026-08-17'), dateFromIso('2026-08-23')],
'This weekend': [dateFromIso('2026-08-15'), dateFromIso('2026-08-16')],
'This month': [dateFromIso('2026-08-01'), dateFromIso('2026-08-31')],
'Next month': [dateFromIso('2026-09-01'), dateFromIso('2026-09-30')],
'Next 30 days': [dateFromIso('2026-08-15'), dateFromIso('2026-09-13')],
} satisfies Record<string, [Date, Date]>
type PresetName = keyof typeof presetRanges
type DateRange = [Date | null, Date | null]
export default function DialogDateTimePicker() {
const [isOpen, setIsOpen] = useState(true)
const [activePreset, setActivePreset] = useState<PresetName | null>('Today')
const [selectedRange, setSelectedRange] = useState<DateRange>(
presetRanges.Today,
)
const [meetingTime, setMeetingTime] = useState<Date | null>(() =>
dateWithTime('2026-08-15', 9, 0),
)
const [meetingTimeRange, setMeetingTimeRange] = useState<DateRange>(() => [
dateWithTime('2026-08-15', 9, 0),
dateWithTime('2026-08-15', 17, 0),
])
const { smaller } = useResponsive()
const isCompact = smaller.md
const isSingleDay =
selectedRange[0] &&
(!selectedRange[1] ||
selectedRange[0].toDateString() === selectedRange[1].toDateString())
const applyPreset = (preset: PresetName) => {
const [start, end] = presetRanges[preset]
setActivePreset(preset)
setSelectedRange([new Date(start), new Date(end)])
}
const handleRangeChange = (range: DateRange) => {
setActivePreset(null)
setSelectedRange(range)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDateTimePickerDescriptionId}
aria-labelledby={dialogDateTimePickerTitleId}
className="p-0"
closable={false}
isOpen={isOpen}
lockScroll
width={780}
onClose={() => setIsOpen(false)}
>
<header className="flex justify-between gap-4 p-4 border-b">
<div className="flex items-center gap-2">
<IconFrame
aria-hidden="true"
className="shrink-0 text-xl"
size={40}
>
<PiClock />
</IconFrame>
<div className="min-w-0">
<h5
className="text-lg font-semibold"
id={dialogDateTimePickerTitleId}
>
Pick a date and time
</h5>
<p
className="text-muted-foreground"
id={dialogDateTimePickerDescriptionId}
>
Choose a preset, or drag across the calendar.
</p>
</div>
</div>
<div>
<CloseButton onClick={() => setIsOpen(false)} />
</div>
</header>
<div className="flex flex-col md:flex-row border-b">
<aside className="w-full shrink-0 border-b p-4 md:w-44 md:border-b-0 md:border-r">
<nav aria-label="Date range presets">
<Menu variant="subtle">
{(Object.keys(presetRanges) as PresetName[]).map(
(preset) => {
const isActive = activePreset === preset
return (
<Menu.MenuItem
key={preset}
eventKey={preset}
isActive={isActive}
role="button"
tabIndex={0}
aria-current={
isActive ? 'true' : undefined
}
onClick={() =>
applyPreset(preset)
}
onKeyDown={(event: React.KeyboardEvent) => {
if (
event.key === 'Enter' ||
event.key === ' '
) {
event.preventDefault()
applyPreset(preset)
}
}}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{preset}
</Menu.MenuItem>
)
},
)}
</Menu>
</nav>
</aside>
<section className="min-w-0 flex-1">
<div className="p-4 border-b">
<Calendar.Range
className="w-full"
dateViewCount={isCompact ? 1 : 2}
defaultMonth={dateFromIso('2026-08-01')}
firstDayOfWeek="monday"
value={selectedRange}
onChange={handleRangeChange}
/>
</div>
<footer className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span>Time: </span>
{isSingleDay ? (
<TimeInput
aria-label="Meeting time"
value={meetingTime}
onChange={setMeetingTime}
/>
) : (
<TimeInput.TimeInputRange
aria-label="Meeting start and end times"
value={meetingTimeRange}
onChange={setMeetingTimeRange}
/>
)}
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button type="button" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Apply
</Button>
</div>
</footer>
</section>
</div>
</Dialog>
</main>
)
}
Dialog 04
Preview
npx nateui@latest add DialogBulkImportDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Dialog from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import Link from '@/components/ui/Link'
import Upload from '@/components/ui/Upload'
import Divider from '@/components/composites/Divider'
import FileIcon from '@/components/composites/FileIcon'
import { PiArrowRight, PiLifebuoy, PiUploadSimple } from 'react-icons/pi'
const dialogTitleId = 'dialog-bulk-import-title'
const urlFieldId = 'dialog-bulk-import-url'
type ImportRow = {
id: string
name: string
size: string
type: string
}
const initialRows: ImportRow[] = [
{
id: 'contacts',
name: 'contacts-export.csv',
size: '4.2 MB',
type: 'csv',
},
{
id: 'accounts',
name: 'account-records.pdf',
size: '1.8 MB',
type: 'pdf',
},
]
export default function DialogBulkImport() {
const [isOpen, setIsOpen] = useState(true)
const [rows, setRows] = useState<ImportRow[]>(initialRows)
const [importUrl, setImportUrl] = useState('')
const handleOpen = () => {
setRows(initialRows)
setImportUrl('')
setIsOpen(true)
}
const handleRemoveRow = (id: string) => {
setRows((current) => current.filter((row) => row.id !== id))
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={handleOpen}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
isOpen={isOpen}
lockScroll
width={500}
onClose={() => setIsOpen(false)}
>
<h5
className="text-lg font-semibold"
id={dialogTitleId}
>
Import files
</h5>
<Upload draggable showList={false} className="mt-4 w-full">
<div className="flex w-full flex-col items-center gap-1 px-4 py-8 text-center">
<div className="size-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<PiUploadSimple
aria-hidden="true"
className="text-xl"
/>
</div>
<p className="mt-2">
Drag files here, or{' '}
<span className="font-medium text-primary">
browse to upload
</span>
</p>
<p className="text-xs text-muted-foreground">
Accepts CSV, JSON, or PDF files up to 10 MB
</p>
</div>
</Upload>
{rows.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{rows.map((row) => (
<div
className="flex items-center gap-4 rounded-card border px-4 py-2"
key={row.id}
>
<FileIcon size={32} type={row.type} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{row.name}
</p>
<p className="text-xs text-muted-foreground">
{row.size}
</p>
</div>
<CloseButton
aria-label={`Remove ${row.name}`}
onClick={() => handleRemoveRow(row.id)}
/>
</div>
))}
</div>
)}
<div className="mt-4 flex items-center gap-4">
<Divider className="my-0 flex-1" />
<span>or</span>
<Divider className="my-0 flex-1" />
</div>
<div className="mt-4">
<label
className="font-medium text-foreground"
htmlFor={urlFieldId}
>
Import from a URL
</label>
<Input
className="mt-2"
id={urlFieldId}
placeholder="https://example.com/records.csv"
suffix={
<Button
aria-label="Submit URL"
size="sm"
type="button"
variant="subtle"
className="!h-6 -mr-2"
>
Upload
</Button>
}
value={importUrl}
onChange={(event) =>
setImportUrl(event.target.value)
}
/>
</div>
<div className="mt-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Import files
</Button>
</div>
</div>
</Dialog>
</main>
)
}
Dialog 05
Preview
npx nateui@latest add DialogTwoFactorSetupDark
import { useEffect, useRef, useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import InputGroup from '@/components/ui/InputGroup'
import Divider from '@/components/composites/Divider'
import IconFrame from '@/components/composites/IconFrame'
import {
PiArrowRight,
PiCheck,
PiCopy,
PiScan,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-two-factor-setup-title'
const dialogDescriptionId = 'dialog-two-factor-setup-description'
export default function DialogTwoFactorSetup() {
const [isOpen, setIsOpen] = useState(true)
const [isCopied, setIsCopied] = useState(false)
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const setupKey = 'K3QT VB7N M2XD 9WFA H6ZR P1LS'
useEffect(() => {
return () => {
if (copyTimeoutRef.current !== null) {
clearTimeout(copyTimeoutRef.current)
}
}
}, [])
const handleCopy = () => {
if (copyTimeoutRef.current !== null) {
clearTimeout(copyTimeoutRef.current)
}
void navigator.clipboard?.writeText(setupKey)
setIsCopied(true)
copyTimeoutRef.current = setTimeout(() => {
setIsCopied(false)
copyTimeoutRef.current = null
}, 1600)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
isOpen={isOpen}
lockScroll
width={420}
onClose={() => setIsOpen(false)}
>
<div className="mt-4 flex flex-col items-center text-center">
<IconFrame
aria-hidden="true"
className="bg-palette-blue-soft text-palette-blue-soft-foreground border-0"
size={48}
>
<PiScan aria-hidden="true" className="text-2xl" />
</IconFrame>
<h5
className="mt-4 text-lg font-semibold"
id={dialogTitleId}
>
Set up two-factor authentication
</h5>
<p
className="mt-1 text-muted-foreground"
id={dialogDescriptionId}
>
Add an account in your authenticator app, then scan this
code.
</p>
</div>
<div className="mt-4 flex justify-center">
<div className="rounded-card border bg-white p-4">
<img
alt="Authenticator setup QR code"
className="block h-30 w-30"
height={120}
src={`${assetBase}/thumbs/misc/qr-code.svg`}
width={120}
/>
</div>
</div>
<Button
block
className="mt-8"
icon={<PiArrowRight />}
iconAlignment="end"
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Continue
</Button>
<div className="mt-4 flex items-center gap-4">
<Divider className="my-0 flex-1" />
<span className="min-w-0 text-center">
Can't scan? Use the setup key
</span>
<Divider className="my-0 flex-1" />
</div>
<InputGroup className="mt-4">
<Input
aria-label="Two-factor setup key"
className="min-w-0 flex-1 font-mono"
readOnly
value={setupKey}
/>
<Button
aria-label={
isCopied ? 'Setup key copied' : 'Copy setup key'
}
className="shrink-0"
icon={isCopied ? <PiCheck /> : <PiCopy />}
type="button"
onClick={handleCopy}
/>
</InputGroup>
</Dialog>
</main>
)
}
Dialog 06
Preview
npx nateui@latest add DialogPlanUpgradeDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import Link from '@/components/ui/Link'
import Radio from '@/components/ui/Radio'
import IconFrame from '@/components/composites/IconFrame'
import classNames from '@/utils/classNames'
import { PiArrowFatLinesUp } from 'react-icons/pi'
const dialogTitleId = 'dialog-plan-upgrade-title'
const dialogDescriptionId = 'dialog-plan-upgrade-description'
const planGroupLabelId = 'dialog-plan-upgrade-group-label'
const assetBase = 'https://statics.nateui.com/img'
type PlanTier = {
id: string
name: string
price: number
description: string
iconSrc: string
}
const planTiers: PlanTier[] = [
{
id: 'basic',
name: 'Basic',
price: 19,
description: 'Core features for a single user.',
iconSrc: `${assetBase}/thumbs/plans/basic.svg`,
},
{
id: 'pro',
name: 'Pro',
price: 49,
description: 'Higher usage limits and priority support.',
iconSrc: `${assetBase}/thumbs/plans/standard.svg`,
},
{
id: 'team',
name: 'Team',
price: 99,
description: 'Shared billing for the whole team.',
iconSrc: `${assetBase}/thumbs/plans/pro.svg`,
},
]
export default function DialogPlanUpgrade() {
const [isOpen, setIsOpen] = useState(true)
const [selectedPlanId, setSelectedPlanId] = useState(planTiers[0].id)
const handleOpen = () => {
setSelectedPlanId(planTiers[0].id)
setIsOpen(true)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={handleOpen}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
isOpen={isOpen}
lockScroll
width={440}
onClose={() => setIsOpen(false)}
>
<IconFrame variant="muted" size={36}>
<PiArrowFatLinesUp
aria-hidden="true"
className="text-xl text-foreground"
/>
</IconFrame>
<h5
className="mt-4 text-lg font-semibold"
id={dialogTitleId}
>
Subscription plans
</h5>
<p
className="text-muted-foreground"
id={dialogDescriptionId}
>
Compare the available tiers before you confirm.
</p>
<p
className="mt-8 mb-2 font-medium text-foreground"
id={planGroupLabelId}
>
Available plans
</p>
<Radio.Group
aria-labelledby={planGroupLabelId}
className="w-full gap-2"
name="dialog-plan-upgrade-tier"
role="radiogroup"
value={selectedPlanId}
vertical
onChange={(value) => setSelectedPlanId(value)}
>
{planTiers.map((plan) => {
const isSelected = plan.id === selectedPlanId
return (
<Radio
className={classNames(
'w-full flex-row-reverse items-start rounded-card border p-4',
isSelected
? 'border-primary bg-primary-soft ring'
: 'hover:bg-accent',
)}
key={plan.id}
value={plan.id}
>
<div className="flex items-start gap-3">
<IconFrame className="shrink-0" size={40}>
<img
alt=""
className="h-6 w-6 object-contain"
src={plan.iconSrc}
/>
</IconFrame>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-1.5">
<span className="font-medium text-foreground">
{plan.name}
</span>
<span className="font-normal">
${plan.price}/mo
</span>
</div>
<p className="mt-1 truncate font-normal text-muted-foreground">
{plan.description}
</p>
</div>
</div>
</Radio>
)
})}
</Radio.Group>
<div className="mt-8 flex flex-col-reverse gap-2 sm:flex-row">
<Button
className="flex-1"
type="button"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Confirm
</Button>
</div>
<div className="mt-4 mb-2 text-center">
<Link className="underline" href="#plan-comparison">
Compare all plans
</Link>
</div>
</Dialog>
</main>
)
}
Dialog 07
Preview
npx nateui@latest add DialogSubscriptionCheckoutDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Dialog from '@/components/ui/Dialog'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Link from '@/components/ui/Link'
import Select from '@/components/ui/Select'
import Switcher from '@/components/ui/Switcher'
import Tag from '@/components/ui/Tag'
import Tooltip from '@/components/ui/Tooltip'
import Divider from '@/components/composites/Divider'
import IconFrame from '@/components/composites/IconFrame'
import { PiArrowLeft, PiCreditCard, PiInfo, PiReceipt } from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-subscription-checkout-title'
const emailFieldId = 'dialog-subscription-checkout-email'
const cardholderFieldId = 'dialog-subscription-checkout-cardholder'
const cardDetailsLabelId = 'dialog-subscription-checkout-card-details-label'
const countryLabelId = 'dialog-subscription-checkout-country-label'
type CountryOption = {
label: string
value: string
}
const countries: CountryOption[] = [
{ value: 'US', label: 'United States' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'CA', label: 'Canada' },
{ value: 'AU', label: 'Australia' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'JP', label: 'Japan' },
{ value: 'SG', label: 'Singapore' },
]
const countryFlag = (code: string) => (
<img
alt=""
className="h-4 w-4 shrink-0"
src={`${assetBase}/countries/${code}.png`}
/>
)
const monthlyAmount = { subtotal: 49, tax: 4.9, total: 53.9 }
const annualAmount = { subtotal: 470, tax: 47, total: 517 }
const formatAmount = (value: number) => `$${value.toFixed(2)}`
const initialFormState = {
email: 'you@example.com',
cardholderName: 'Jordan Blake',
cardNumber: '1234 1234 1234 1234',
expiry: '01 / 29',
securityCode: '123',
address: '100 Market Street',
saveDetails: true,
}
const joinedGroup = [
'flex flex-col',
'[&_.input]:relative [&_.select]:relative',
'[&_.input:focus]:z-20 [&_.input:focus-within]:z-20',
'[&_.select.select-menu-open]:z-30',
].join(' ')
export default function DialogSubscriptionCheckout() {
const [isOpen, setIsOpen] = useState(true)
const [isAnnual, setIsAnnual] = useState(false)
const [email, setEmail] = useState(initialFormState.email)
const [cardholderName, setCardholderName] = useState(
initialFormState.cardholderName,
)
const [cardNumber, setCardNumber] = useState(initialFormState.cardNumber)
const [expiry, setExpiry] = useState(initialFormState.expiry)
const [securityCode, setSecurityCode] = useState(
initialFormState.securityCode,
)
const [country, setCountry] = useState<CountryOption>(countries[0])
const [address, setAddress] = useState(initialFormState.address)
const [saveDetails, setSaveDetails] = useState(
initialFormState.saveDetails,
)
const amount = isAnnual ? annualAmount : monthlyAmount
const handleOpen = () => {
setIsAnnual(false)
setEmail(initialFormState.email)
setCardholderName(initialFormState.cardholderName)
setCardNumber(initialFormState.cardNumber)
setExpiry(initialFormState.expiry)
setSecurityCode(initialFormState.securityCode)
setCountry(countries[0])
setAddress(initialFormState.address)
setSaveDetails(initialFormState.saveDetails)
setIsOpen(true)
}
const handleClose = () => setIsOpen(false)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={handleOpen}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
className="flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden p-0"
isOpen={isOpen}
lockScroll
width={900}
onClose={handleClose}
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto md:flex-row">
<div className="p-2">
<div className="flex flex-col bg-background p-8 md:w-[380px] rounded-card border">
<div className="flex items-center gap-2">
<Button
aria-label="Back"
icon={<PiArrowLeft aria-hidden="true" />}
size="sm"
type="button"
variant="ghost"
onClick={handleClose}
/>
<div>
<h5
className="text-lg font-semibold text-foreground"
id={dialogTitleId}
>
Order summary
</h5>
</div>
</div>
<div className="mt-8 flex items-end gap-2">
<IconFrame size={36} variant="elavated">
<img
alt=""
className="h-6 w-6 object-contain"
src="https://statics.nateui.com/img/thumbs/plans/pro.svg"
/>
</IconFrame>
<div className="flex items-end gap-2">
<span className="text-2xl font-semibold ">
{formatAmount(amount.subtotal)}
</span>
<span className="flex mb-1 text-xs text-muted-foreground">
<span>per {isAnnual ? 'year' : 'month'}</span>
</span>
</div>
</div>
<p className="mt-4 text-muted-foreground">
Includes unlimited projects and priority support.
</p>
<div className="mt-8 rounded-card border bg-card p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate font-medium text-card-foreground">
Pro plan
</p>
<p className="truncate text-muted-foreground">
{isAnnual
? 'Billed annually'
: 'Billed monthly'}
</p>
</div>
<p className="shrink-0 text-card-foreground tabular-nums">
{formatAmount(amount.subtotal)}
</p>
</div>
<Divider className="my-4" />
<div className="flex items-center gap-2">
<Switcher
aria-label="Bill annually"
checked={isAnnual}
onChange={setIsAnnual}
/>
<Tag>Save 20%</Tag>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
Bill annually
</span>
<span className="shrink-0 text-card-foreground tabular-nums">
{formatAmount(annualAmount.subtotal)}
</span>
</div>
</div>
<div className="mt-8 flex flex-col divide-y">
<div className="flex items-center justify-between gap-4 py-4">
<span>
Subtotal
</span>
<span className="tabular-nums">
{formatAmount(amount.subtotal)}
</span>
</div>
<div className="py-4">
<Link
href="#promo-code"
>
Add promo code
</Link>
</div>
<div className="flex items-center justify-between gap-4 py-4">
<span className="flex items-center gap-1">
Tax
<Tooltip title="Calculated from your billing address">
<PiInfo
aria-label="How tax is calculated"
className="text-sm"
tabIndex={0}
/>
</Tooltip>
</span>
<span className="tabular-nums">
{formatAmount(amount.tax)}
</span>
</div>
<div className="flex items-center justify-between gap-4 py-4">
<span className="font-semibold">Total</span>
<span className="font-semibold tabular-nums">
{formatAmount(amount.total)}
</span>
</div>
</div>
<div className="mt-auto flex items-center gap-4 pt-8">
<Link
className="text-muted-foreground hover:text-foreground"
href="#terms"
>
Terms of service
</Link>
<Link
className="text-muted-foreground hover:text-foreground"
href="#privacy"
>
Privacy policy
</Link>
</div>
</div>
</div>
<div className="flex flex-col p-8 pe-12 md:min-w-0 md:flex-1">
<Form onSubmit={(event) => event.preventDefault()}>
<h5 className="text-lg font-semibold text-foreground">
Payment details
</h5>
<div className="mt-4">
<Form.Field
htmlFor={emailFieldId}
label="Email"
>
<Input
id={emailFieldId}
type="email"
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor={cardholderFieldId}
label="Cardholder name"
>
<Input
id={cardholderFieldId}
value={cardholderName}
onChange={(event) =>
setCardholderName(
event.target.value,
)
}
/>
</Form.Field>
<Form.Field
aria-labelledby={cardDetailsLabelId}
label="Card details"
labelId={cardDetailsLabelId}
role="group"
>
<div className={joinedGroup}>
<Input
aria-label="Card number"
className="rounded-b-none"
inputMode="numeric"
suffix={
<PiCreditCard
aria-hidden="true"
className="text-base text-muted-foreground"
/>
}
value={cardNumber}
onChange={(event) =>
setCardNumber(
event.target.value,
)
}
/>
<div className="-mt-px flex">
<Input
aria-label="Expiration date"
className="min-w-0 rounded-t-none rounded-br-none"
inputMode="numeric"
value={expiry}
onChange={(event) =>
setExpiry(
event.target.value,
)
}
/>
<Input
aria-label="Security code"
className="-ml-px min-w-0 rounded-t-none rounded-bl-none"
inputMode="numeric"
value={securityCode}
onChange={(event) =>
setSecurityCode(
event.target.value,
)
}
/>
</div>
</div>
</Form.Field>
<Form.Field
aria-labelledby={countryLabelId}
label="Billing address"
labelId={countryLabelId}
role="group"
>
<div className={joinedGroup}>
<Select
className="rounded-b-none"
customInputDisplay={(selected) => (
<Select.ValueWithPrefix
label={selected?.label}
prefix={countryFlag(
selected?.value ??
'US',
)}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
checkIcon={CheckIcon}
label={option.label}
prefix={countryFlag(
option.value,
)}
selected={selected}
/>
)}
inputId={countryLabelId}
options={countries}
value={country}
onChange={(option) =>
setCountry(option)
}
/>
<Input
aria-label="Street address"
className="-mt-px rounded-t-none"
value={address}
onChange={(event) =>
setAddress(
event.target.value,
)
}
/>
</div>
</Form.Field>
<div className="rounded-card border p-4">
<Checkbox
checked={saveDetails}
className="w-full items-start [&>span:first-child]:h-5"
contentClass="min-w-0 flex-1"
onChange={setSaveDetails}
>
<span className="flex flex-col gap-1">
<span className="font-medium text-card-foreground">
Save payment details
</span>
<span className="font-normal text-muted-foreground">
Use this card for future
renewals.
</span>
</span>
</Checkbox>
</div>
</div>
<Button
block
className="mt-8"
type="button"
variant="solid"
onClick={handleClose}
>
Subscribe for {formatAmount(amount.total)}
</Button>
</Form>
</div>
</div>
</Dialog>
</main>
)
}
Dialog 08
Preview
npx nateui@latest add DialogConnectAccountDark
import { useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import Link from '@/components/ui/Link'
import Divider from '@/components/composites/Divider'
import { PiLightning, PiShieldCheck } from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-connect-account-title'
export default function DialogConnectAccount() {
const [isOpen, setIsOpen] = useState(true)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
isOpen={isOpen}
lockScroll
width={380}
onClose={() => setIsOpen(false)}
>
<div className="mt-12 flex justify-center">
<Avatar.Group chained>
<Avatar
alt="NateUI"
className="bg-white p-1"
src={`${assetBase}/logos/logo-collapsed.svg`}
size={52}
/>
<Avatar
alt="Stripe"
className="bg-white p-1"
src={`${assetBase}/thumbs/brands/stripe.png`}
size={52}
/>
</Avatar.Group>
</div>
<div
className="mt-4 text-center text-lg px-8"
id={dialogTitleId}
>
NateUI uses <span className="font-bold">Stripe</span> to
connect your bank
</div>
<div className="mt-8 rounded-card border p-4">
<div className="flex items-start gap-4">
<div className="mt-1">
<PiLightning
aria-hidden="true"
className="shrink-0 text-lg text-foreground"
/>
</div>
<div className="min-w-0">
<p className="font-semibold">Linked in a few taps</p>
<p className="mt-1 text-muted-foreground">
Pick your institution, sign in once, and the
connection stays live.
</p>
</div>
</div>
<div className="mt-4 flex items-start gap-4">
<div className="mt-1">
<PiShieldCheck
aria-hidden="true"
className="shrink-0 text-lg text-foreground"
/>
</div>
<div className="min-w-0">
<p className="font-semibold">
Your credentials stay private
</p>
<p className="mt-1 text-muted-foreground">
Sign-in happens on your bank's side. NateUI
only ever sees the balances you approve.
</p>
</div>
</div>
</div>
<Divider className="-mx-4 mb-0 mt-8 w-auto" />
<p
className="mt-4 text-center text-xs text-muted-foreground"
>
Continuing means you accept Stripe's<br />
<Link className="underline outline-none" href="#">
Privacy Policy
</Link>{' '}
and{' '}
<Link className="underline outline-none" href="#">
Term
</Link>
.
</p>
<Button
block
type="button"
variant="solid"
className="mt-4"
onClick={() => setIsOpen(false)}
>
Get started
</Button>
</Dialog>
</main>
)
}
Dialog 09
Preview
npx nateui@latest add DialogCommandPaletteDark
import { useState } from 'react'
import Search from '@/components/patterns/Search'
import type { SearchData, SearchResult } from '@/components/patterns/Search'
import {
PiFiles,
PiLightning,
PiUser,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const defaultQuickActions: SearchResult[] = [
{
title: 'Favourites',
data: [
{
type: 'quickAction',
title: 'New document',
icon: 'document',
path: '#new-document',
shortcut: 'N',
},
{
type: 'quickAction',
title: 'Save workspace',
icon: 'task',
path: '#save-workspace',
shortcut: '⌘+S',
},
],
},
{
title: 'Actions',
data: [
{
type: 'quickAction',
title: 'Add sales funnel',
icon: 'product',
path: '#add-sales-funnel',
},
{
type: 'quickAction',
title: 'Find broken links',
icon: 'settings',
path: '#find-broken-links',
},
{
type: 'quickAction',
title: 'Draft release notes',
icon: 'note',
path: '#draft-release-notes',
},
],
},
{
title: 'Files',
data: [
{
type: 'files',
title: 'Q3 pipeline export',
fileType: 'xlsx',
path: '#q3-pipeline-export',
},
{
type: 'files',
title: 'Onboarding checklist',
fileType: 'pdf',
path: '#onboarding-checklist',
},
],
},
{
title: 'People',
data: [
{
type: 'profile',
img: `${assetBase}/avatars/thumb-1.jpg`,
id: 'maya-chen',
title: 'Maya Chen',
subtitle: 'Operations lead',
path: '#maya-chen',
},
{
type: 'profile',
img: `${assetBase}/avatars/thumb-2.jpg`,
id: 'noah-patel',
title: 'Noah Patel',
subtitle: 'Product manager',
path: '#noah-patel',
},
],
},
]
const categoryFilters = [
{
value: 'profile',
label: 'People',
icon: <PiUser className="text-sm" />,
},
{
value: 'files',
label: 'Files',
icon: <PiFiles className="text-sm" />,
},
{
value: 'quickAction',
label: 'Actions',
icon: <PiLightning className="text-sm" />,
},
]
const itemMatchesQuery = (item: SearchData, query: string) => {
const searchableFields = [item.title]
if (item.type === 'profile') {
searchableFields.push(item.subtitle)
}
if (item.type === 'files') {
searchableFields.push(item.fileType)
}
return searchableFields.some((field) =>
field.toLowerCase().includes(query),
)
}
export default function DialogCommandPalette() {
const [searchResults, setSearchResults] = useState<
SearchResult[] | undefined
>(undefined)
const handleQueryChange = (query: string) => {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
setSearchResults(undefined)
return
}
setSearchResults(
defaultQuickActions
.map((result) => ({
...result,
data: result.data.filter((item) =>
itemMatchesQuery(item, normalizedQuery),
),
}))
.filter((result) => result.data.length > 0),
)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Search
defaultOpen
trigger="input"
data={searchResults}
defaultQuickActions={defaultQuickActions}
categoryFilters={categoryFilters}
onQueryChange={handleQueryChange}
onNavigate={() => {}}
classNames={{ root: 'w-full max-w-md' }}
/>
</main>
)
}
Dialog 10
Preview
npx nateui@latest add DialogBugReportDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Dialog from '@/components/ui/Dialog'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Tooltip from '@/components/ui/Tooltip'
import Upload from '@/components/ui/Upload'
import IconFrame from '@/components/composites/IconFrame'
import classNames from '@/utils/classNames'
import { PiBug, PiCloudArrowUp, PiQuestion } from 'react-icons/pi'
const dialogTitleId = 'dialog-bug-report-title'
const titleFieldId = 'dialog-bug-report-title-field'
const descriptionFieldId = 'dialog-bug-report-description'
const pathFieldId = 'dialog-bug-report-path'
const priorityFieldId = 'dialog-bug-report-priority'
type PriorityOption = {
label: string
value: string
dotClassName: string
}
const priorityOptions: PriorityOption[] = [
{ label: 'Low', value: 'low', dotClassName: 'bg-success' },
{ label: 'Medium', value: 'medium', dotClassName: 'bg-warning' },
{ label: 'High', value: 'high', dotClassName: 'bg-destructive' },
]
const PriorityDot = ({ className }: { className: string }) => (
<span className={classNames('size-2 rounded-full', className)} />
)
export default function DialogBugReport() {
const [isOpen, setIsOpen] = useState(true)
const [issueTitle, setIssueTitle] = useState('')
const [issueDescription, setIssueDescription] = useState('')
const [pagePath, setPagePath] = useState('')
const [priority, setPriority] = useState<PriorityOption>(
priorityOptions[0],
)
const [isDragActive, setIsDragActive] = useState(false)
const handleOpen = () => {
setIssueTitle('')
setIssueDescription('')
setPagePath('')
setPriority(priorityOptions[0])
setIsDragActive(false)
setIsOpen(true)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={handleOpen}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
className="flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden p-0"
isOpen={isOpen}
lockScroll
width={540}
closable={false}
onClose={() => setIsOpen(false)}
>
<div className="flex item-start justify-between gap-2 p-4 border-b">
<div className="flex items-center gap-2 pe-12">
<IconFrame variant="layered">
<PiBug
aria-hidden="true"
className="text-xl text-foreground"
/>
</IconFrame>
<div>
<h5
className="text-lg font-semibold"
id={dialogTitleId}
>
Report a bug
</h5>
<p className="text-muted-foreground">
Describe what happened so the team can take a
look.
</p>
</div>
</div>
<div>
<CloseButton onClick={() => setIsOpen(false)} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<Form onSubmit={(event) => event.preventDefault()}>
<Form.Field
htmlFor={titleFieldId}
label="Title"
>
<Input
id={titleFieldId}
placeholder="Enter issue title"
value={issueTitle}
onChange={(event) =>
setIssueTitle(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor={descriptionFieldId}
label="Description"
>
<Input
id={descriptionFieldId}
placeholder="Enter issue description"
rows={4}
textArea
value={issueDescription}
onChange={(event) =>
setIssueDescription(event.target.value)
}
/>
</Form.Field>
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
<Form.Field
htmlFor={pathFieldId}
label="URL or path"
>
<Input
id={pathFieldId}
placeholder="Enter issue related URL"
suffix={
<Tooltip title="The page or route where this happened">
<PiQuestion
aria-label="What to enter here"
className="text-base"
tabIndex={0}
/>
</Tooltip>
}
value={pagePath}
onChange={(event) =>
setPagePath(event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor={priorityFieldId}
label="Priority"
>
<Select<{ dotClassName: string }>
customInputDisplay={(selectedItem) =>
selectedItem ? (
<Select.ValueWithPrefix
label={selectedItem.label}
prefix={
<PriorityDot
className={
selectedItem.dotClassName
}
/>
}
/>
) : null
}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
checkIcon={CheckIcon}
label={option.label}
prefix={
<PriorityDot
className={
option.dotClassName
}
/>
}
selected={selected}
/>
)}
inputId={priorityFieldId}
options={priorityOptions}
value={priority}
onChange={(option) => setPriority(option)}
/>
</Form.Field>
</div>
<Form.Field label="Attachments">
<Upload
showList={false}
className={classNames(
'w-full cursor-pointer rounded-control border-2 border-dashed',
isDragActive && 'border-primary',
)}
onDragLeave={() => setIsDragActive(false)}
onDragOver={(event) => {
event.preventDefault()
setIsDragActive(true)
}}
onDrop={(event) => {
event.preventDefault()
setIsDragActive(false)
}}
>
<div className="flex w-full flex-col items-center gap-1 px-4 py-8 text-center">
<PiCloudArrowUp
aria-hidden="true"
className="text-2xl"
/>
<p className="mt-2">
Drop a file here, or choose one to
upload.
</p>
<p className="text-xs text-muted-foreground">
Accepts PNG, JPG, or PDF files up to 5
MB
</p>
<Button
className="mt-2"
size="sm"
type="button"
>
Browse files
</Button>
</div>
</Upload>
</Form.Field>
</Form>
</div>
<div className="flex items-center justify-between gap-2 border-t p-4">
<Button type="button" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Submit report
</Button>
</div>
</Dialog>
</main>
)
}
Dialog 11
Preview
npx nateui@latest add DialogInviteMembersDark
import { useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Tag from '@/components/ui/Tag'
import Divider from '@/components/composites/Divider'
import { PiDotsThreeOutlineVerticalBold } from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-invite-members-title'
const dialogDescriptionId = 'dialog-invite-members-description'
const roleSelectId = 'dialog-invite-members-role'
type RoleOption = {
label: string
value: string
}
const roleOptions: RoleOption[] = [
{ label: 'Member', value: 'member' },
{ label: 'Admin', value: 'admin' },
]
type ActiveMember = {
id: string
name: string
email: string
role: string
avatarSrc: string
}
const activeMembers: ActiveMember[] = [
{
id: 'morgan',
name: 'Morgan Ellis',
email: 'morgan@example.com',
role: 'Admin',
avatarSrc: `${assetBase}/avatars/thumb-1.jpg`,
},
{
id: 'priya',
name: 'Priya Nandan',
email: 'priya@example.com',
role: 'Member',
avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
},
{
id: 'sam',
name: 'Sam Whitfield',
email: 'sam@example.com',
role: 'Member',
avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
},
{
id: 'casey',
name: 'Casey Okafor',
email: 'casey@example.com',
role: 'Member',
avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
},
]
type PendingMember = {
id: string
name: string
email: string
role: string
initials: string
}
const pendingMembers: PendingMember[] = [
{
id: 'devon',
name: 'Devon Blake',
email: 'devon@example.com',
role: 'Member',
initials: 'DB',
},
{
id: 'harper',
name: 'Harper Lin',
email: 'harper@example.com',
role: 'Member',
initials: 'HL',
},
]
export default function DialogInviteMembers() {
const [isOpen, setIsOpen] = useState(true)
const [inviteEmail, setInviteEmail] = useState('')
const [selectedRole, setSelectedRole] = useState<RoleOption>(
roleOptions[0],
)
const handleOpen = () => {
setInviteEmail('')
setSelectedRole(roleOptions[0])
setIsOpen(true)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={handleOpen}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
className="flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden p-0"
isOpen={isOpen}
lockScroll
width={600}
onClose={() => setIsOpen(false)}
>
<div className="p-4 pe-12">
<h5
className="text-lg font-semibold"
id={dialogTitleId}
>
Team access
</h5>
<p
className="mt-1 text-muted-foreground"
id={dialogDescriptionId}
>
Invite new teammates by email and see who already has
access to this workspace.
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
aria-label="Email address"
className="sm:flex-1"
placeholder="name@company.com"
type="email"
value={inviteEmail}
onChange={(event) =>
setInviteEmail(event.target.value)
}
/>
<span className="sr-only" id={roleSelectId}>
Role
</span>
<Select
className="sm:w-40"
inputId={roleSelectId}
options={roleOptions}
value={selectedRole}
onChange={(option) => setSelectedRole(option)}
/>
<Button type="button" variant="solid">
Send invite
</Button>
</div>
<div className="mt-8">
<h6 className="text-base font-medium text-foreground">
Current members
</h6>
<Divider className="mt-2 mb-0" />
</div>
<div className="mt-2 divide-y">
{activeMembers.map((member) => (
<div
className="flex items-center gap-3 py-3"
key={member.id}
>
<Avatar
alt={member.name}
size="sm"
src={member.avatarSrc}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{member.name}
</p>
<p className="truncate text-muted-foreground">
{member.email}
</p>
</div>
<Tag size="sm">{member.role}</Tag>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${member.name}`}
icon={
<PiDotsThreeOutlineVerticalBold />
}
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item>Change role</Dropdown.Item>
<Dropdown.Item>
Remove member
</Dropdown.Item>
</Dropdown>
</div>
))}
</div>
<div className="mt-8">
<h6 className="text-base font-medium text-foreground">
Pending invitations
</h6>
<Divider className="mt-2 mb-0" />
</div>
<div className="mt-2 divide-y">
{pendingMembers.map((member) => (
<div
className="flex items-center gap-3 py-3"
key={member.id}
>
<Avatar
className="border-dashed"
size="sm"
>
{member.initials}
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{member.name}
</p>
<p className="truncate text-muted-foreground">
{member.email}
</p>
</div>
<Tag size="sm">{member.role}</Tag>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${member.name}`}
icon={
<PiDotsThreeOutlineVerticalBold />
}
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item>
Resend invite
</Dropdown.Item>
<Dropdown.Item>
Cancel invitation
</Dropdown.Item>
</Dropdown>
</div>
))}
</div>
</div>
</Dialog>
</main>
)
}
Dialog 12
Preview
npx nateui@latest add DialogTaskDetailsDark
import {
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { ComponentProps, ReactNode, Ref } from 'react'
import dayjs from 'dayjs'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Dialog from '@/components/ui/Dialog'
import Popover from '@/components/ui/Popover'
import Scroll from '@/components/ui/Scroll'
import Tabs from '@/components/ui/Tabs'
import Tag from '@/components/ui/Tag'
import Divider from '@/components/composites/Divider'
import EmptyState from '@/components/composites/EmptyState'
import FileIcon from '@/components/composites/FileIcon'
import RichTextEditor from '@/components/composites/RichTextEditor'
import UsersAvatarGroup from '@/components/composites/UsersAvatarGroup'
import classNames from '@/utils/classNames'
import sleep from '@/utils/sleep'
import {
PiArrowsDownUp,
PiBookmarkSimple,
PiCalendarBlank,
PiChatCircleSlash,
PiCheck,
PiCheckSquare,
PiClock,
PiDownloadSimple,
PiFileText,
PiPaperPlaneTilt,
PiTag,
PiUser,
PiX,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-task-details-title'
type Priority = 'urgent' | 'high' | 'normal' | 'low'
type Member = {
id: string
name: string
img: string
}
type Comment = {
id: string
author: Member
createdAt: string
message: string
}
type Subtask = {
id: string
title: string
assigneeIds: string[]
priority: Priority
dueDate: string
completed: boolean
}
type Attachment = {
id: string
name: string
type: string
size: string
}
const members: Member[] = [
{
id: 'mara',
name: 'Mara Chen',
img: `${assetBase}/avatars/thumb-1.jpg`,
},
{
id: 'jonah',
name: 'Jonah Ellis',
img: `${assetBase}/avatars/thumb-7.jpg`,
},
{
id: 'nina',
name: 'Nina Patel',
img: `${assetBase}/avatars/thumb-12.jpg`,
},
]
const priorities: Array<{
value: Priority
label: string
dotClass: string
}> = [
{ value: 'urgent', label: 'Urgent', dotClass: 'bg-destructive' },
{ value: 'high', label: 'High', dotClass: 'bg-warning' },
{ value: 'normal', label: 'Normal', dotClass: 'bg-info' },
{ value: 'low', label: 'Low', dotClass: 'bg-muted-foreground' },
]
const tagOptions = [
{ id: 'launch', label: 'Launch', dotClass: 'bg-primary' },
{ id: 'research', label: 'Research', dotClass: 'bg-info' },
{ id: 'design', label: 'Design', dotClass: 'bg-warning' },
{ id: 'blocked', label: 'Blocked', dotClass: 'bg-destructive' },
{ id: 'venue', label: 'Venue', dotClass: 'bg-success' },
{ id: 'follow-up', label: 'Follow-up', dotClass: 'bg-muted-foreground' },
]
const initialComments: Comment[] = [
{
id: 'comment-1',
author: members[1],
createdAt: '2026-08-12T09:24:00.000Z',
message:
'The event outline is ready for review. I added the open questions to the checklist.',
},
{
id: 'comment-2',
author: members[2],
createdAt: '2026-08-12T13:40:00.000Z',
message:
'I can take the follow-up with the venue once the final guest count is confirmed.',
},
]
const initialSubtasks: Subtask[] = [
{
id: 'subtask-1',
title: 'Confirm speaker availability',
assigneeIds: ['jonah'],
priority: 'high',
dueDate: '2026-08-18T00:00:00.000Z',
completed: true,
},
{
id: 'subtask-2',
title: 'Share the event outline',
assigneeIds: ['nina'],
priority: 'normal',
dueDate: '2026-08-20T00:00:00.000Z',
completed: false,
},
{
id: 'subtask-3',
title: 'Publish the attendee reminder',
assigneeIds: ['mara'],
priority: 'low',
dueDate: '2026-08-22T00:00:00.000Z',
completed: false,
},
]
const initialAttachments: Attachment[] = [
{ id: 'attachment-1', name: 'event-outline.pdf', type: 'pdf', size: '1.8 MB' },
{ id: 'attachment-2', name: 'speaker-notes.docx', type: 'docx', size: '640 KB' },
{ id: 'attachment-3', name: 'venue-layout.png', type: 'png', size: '2.4 MB' },
]
const getMember = (id: string) =>
members.find((member) => member.id === id) ?? members[0]
type FieldOptions = ComponentProps<'div'> & { label: string; icon: ReactNode }
const Field = ({ label, icon, children }: FieldOptions) => {
return (
<div className="flex items-start gap-2">
<span className="flex min-h-10 shrink-0 items-center gap-1.5 min-w-[120px] sm:min-w-[150px] font-medium">
<span className="text-base">{icon}</span>
<span>{label}:</span>
</span>
<div className="flex min-w-0 flex-1 items-center">{children}</div>
</div>
)
}
const SelectorWraper = ({
children,
editable = true,
wrap = false,
className,
...rest
}: ComponentProps<'span'> & { editable?: boolean; wrap?: boolean }) => {
return (
<span
className={classNames(
'py-1 px-2 rounded-lg sm:min-w-[250px] inline-flex items-center max-w-[450px] h-full min-h-[40px] gap-2',
wrap ? 'flex-wrap' : 'flex-nowrap',
editable &&
'hover:bg-accent focus-within:bg-accent cursor-pointer',
className,
)}
{...rest}
>
{children}
</span>
)
}
type PrioritySelectorOptions = ComponentProps<'span'> & {
value?: string
list: Array<{ value: string; label: string; indicator: string | ReactNode }>
onValueChange: (value: string) => void
}
const PrioritySelector = ({
children,
list,
value,
onValueChange,
}: PrioritySelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
width={220}
>
<ul>
{list.map((item) => (
<li
className={classNames(
'flex items-center justify-between cursor-pointer font-medium px-3 rounded-md w-full whitespace-nowrap gap-x-2 transition-colors duration-150 text-foreground hover:bg-accent h-[36px]',
item.value === value && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
setPopoverOpen(false)
}}
role="option"
aria-selected={item.value === value}
tabIndex={-1}
>
<span className="flex items-center gap-2">
{item.indicator}
<span>{item.label}</span>
</span>
{item.value === value && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Popover>
)
}
type DuedateSelectorOptions = ComponentProps<'div'> & {
value?: string
onValueChange: (value: string) => void
}
const DuedateSelector = ({
children,
value,
onValueChange,
}: DuedateSelectorOptions) => {
const [datePickerOpen, setDatePickerOpen] = useState(false)
const handleValueChange = (date: Date) => {
onValueChange(dayjs(date).toISOString())
setDatePickerOpen(false)
}
return (
<Popover
renderTrigger={children}
open={datePickerOpen}
placement="bottom-start"
onOpenChange={setDatePickerOpen}
style={{ width: 280 }}
>
<Calendar
value={dayjs(value).toDate()}
onChange={handleValueChange}
/>
</Popover>
)
}
type AssigneeSelectorOptions = ComponentProps<'span'> & {
value?: string[]
list: Array<{ value: string; label: string; indicator: string | ReactNode }>
onValueChange: (value: string) => void
}
const AssigneeSelector = ({
children,
list,
value,
onValueChange,
}: AssigneeSelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
style={{ width: 230 }}
>
<Scroll className="h-[170px]">
<ul className="ltr:pr-1.5 rtl:pl-1.5">
{list.map((item) => (
<li
className={classNames(
'flex items-center justify-between cursor-pointer font-medium px-3 rounded-md w-full whitespace-nowrap gap-x-2 transition-colors duration-150 text-foreground hover:bg-accent h-[36px]',
value?.includes(item.value) && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
}}
role="option"
aria-selected={value?.includes(item.value)}
tabIndex={-1}
>
<span className="flex items-center gap-2">
{item.indicator}
<span>{item.label}</span>
</span>
{value?.includes(item.value) && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Scroll>
</Popover>
)
}
type TagsSelectorOptions = ComponentProps<'span'> & {
value?: string[]
list: Array<{ value: string; label: string }>
onValueChange: (value: string) => void
}
const TagsSelector = ({
children,
list,
value,
onValueChange,
}: TagsSelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
width={220}
>
<Scroll className="h-[170px]">
<ul className="ltr:pr-1.5 rtl:pl-1.5">
{list.map((item) => (
<li
className={classNames(
'flex items-center justify-between cursor-pointer font-medium px-3 rounded-md w-full whitespace-nowrap gap-x-2 transition-colors duration-150 text-foreground hover:bg-accent h-[36px]',
value?.includes(item.value) && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
}}
role="option"
aria-selected={value?.includes(item.value)}
tabIndex={-1}
>
<span className="flex items-center gap-2">
<span>{item.label}</span>
</span>
{value?.includes(item.value) && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Scroll>
</Popover>
)
}
const SubjectEditor = ({
className,
value,
onValueChange,
}: ComponentProps<'div'> & {
value: string
onValueChange: (value: string) => void
}) => {
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleEdit = async () => {
setEditing(true)
await sleep(10)
inputRef.current?.focus()
}
const handleChange = () => {
if (inputRef.current) {
onValueChange(inputRef.current.value)
setEditing(false)
inputRef.current.blur()
}
}
const handleClose = () => {
setEditing(false)
inputRef.current?.blur()
}
return (
<>
{editing ? (
<span
className={classNames(
'flex items-center justify-between',
className,
)}
>
<input
ref={inputRef}
defaultValue={value}
className="h-full w-full outline-0 min-h-[21px] leading-normal"
onBlur={handleClose}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
handleChange()
}
}}
onClick={(e) => {
e.stopPropagation()
inputRef.current?.focus()
}}
/>
<span className="flex gap-2">
<button type="button" onMouseDown={handleChange}>
<PiCheck className="hover:text-success" />
</button>
<button
type="button"
onMouseDown={handleClose}
className="hover:text-destructive"
>
<PiX />
</button>
</span>
</span>
) : (
<span
onClick={handleEdit}
className={classNames('cursor-pointer', className)}
>
{value}
</span>
)}
</>
)
}
type CommentInputRef = {
handleFocus: () => void
}
type CommentInputOptions = {
onSubmit: ({ message }: { message: string }) => void
onCancel?: () => void
onChange?: (value: string) => void
className?: string
ref?: Ref<CommentInputRef>
extraTools?: ReactNode
}
const CommentInput = ({
onSubmit,
onChange,
onCancel,
className,
extraTools,
ref,
}: CommentInputOptions) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: {
keepMarks: true,
},
orderedList: {
keepMarks: true,
},
}),
],
editorProps: {
attributes: {
class: 'focus:outline-hidden min-h-[75px] p-3 prose prose-p:text-sm',
},
},
immediatelyRender: false,
onUpdate: ({ editor: nextEditor }) => {
const textContent = nextEditor.getText()
onChange?.(textContent || '')
},
})
const handleSubmit = () => {
if (editor) {
onSubmit({ message: editor.getText() })
editor.commands.clearContent()
}
}
const handleFocus = useCallback(() => {
editor?.commands.focus()
}, [editor])
useImperativeHandle(ref, () => {
return {
handleFocus,
}
}, [handleFocus])
return (
<div
className={classNames(
'border border-border rounded-lg min-h-[120px] shadow flex flex-col',
className,
)}
>
<div>
<div>
<EditorContent className="h-full" editor={editor} />
</div>
<div className="flex items-center justify-between px-4 py-2">
<div className="flex justify-center gap-x-1 gap-y-2">
{editor && (
<>
<RichTextEditor.ToolButtonBold editor={editor} />
<RichTextEditor.ToolButtonItalic editor={editor} />
<RichTextEditor.ToolButtonStrike editor={editor} />
<RichTextEditor.ToolButtonBulletList editor={editor} />
<RichTextEditor.ToolButtonOrderedList editor={editor} />
<RichTextEditor.ToolButtonHorizontalRule editor={editor} />
{extraTools}
</>
)}
</div>
<div className="flex items-center gap-2">
{onCancel && <Button onClick={onCancel}>Cancel</Button>}
<Button
size="sm"
variant="solid"
icon={<PiPaperPlaneTilt className="text-xl" />}
onClick={handleSubmit}
/>
</div>
</div>
</div>
</div>
)
}
export default function DialogTaskDetails() {
const [isOpen, setIsOpen] = useState(true)
const [subject, setSubject] = useState('Prepare autumn customer event')
const [priority, setPriority] = useState<Priority>('high')
const [dueDate, setDueDate] = useState('2026-08-20T00:00:00.000Z')
const [assigneeIds, setAssigneeIds] = useState<string[]>(['mara'])
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([
'launch',
'design',
])
const [description, setDescription] = useState(
'Coordinate the autumn customer event from the first speaker check-in through the attendee follow-up.',
)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [editedDescription, setEditedDescription] = useState('')
const [comments, setComments] = useState<Comment[]>(initialComments)
const [subtasks, setSubtasks] = useState<Subtask[]>(initialSubtasks)
const [attachments] = useState<Attachment[]>(initialAttachments)
const [isSaving, setIsSaving] = useState(false)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
const priorityOptions = useMemo(
() =>
priorities.map((item) => ({
label: item.label,
value: item.value,
indicator: (
<span
className={classNames(
'h-2.5 w-2.5 rounded-xs',
item.dotClass,
)}
></span>
),
})),
[],
)
const assigneeOptions = useMemo(
() =>
members.map((member) => ({
label: member.name,
value: member.id,
indicator: (
<Avatar src={member.img} size={25} />
),
})),
[],
)
const tagsList = useMemo(
() => tagOptions.map((tag) => ({ value: tag.id, label: tag.label })),
[],
)
const handleCommentSubmit = ({ message }: { message: string }) => {
setComments((currentComments) => [
...currentComments,
{
id: `comment-${currentComments.length + 1}`,
author: members[0],
message,
createdAt: '2026-08-15T10:15:00.000Z',
},
])
}
const handleSave = () => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current)
}
setIsSaving(true)
saveTimeoutRef.current = setTimeout(() => {
setIsSaving(false)
setIsOpen(false)
saveTimeoutRef.current = null
}, 500)
}
const handleSubTaskCheck = (id: string, checked: boolean) => {
setSubtasks((currentSubtasks) =>
currentSubtasks.map((subtask) =>
subtask.id === id ? { ...subtask, completed: checked } : subtask,
),
)
}
const handleMutate = ({ key, value }: { key: string; value: string }) => {
if (key === 'priority') {
setPriority(value as Priority)
}
if (key === 'dueDate') {
setDueDate(value)
}
if (key === 'assignee') {
setAssigneeIds((currentIds) =>
currentIds.includes(value)
? currentIds.filter((id) => id !== value)
: [...currentIds, value],
)
}
if (key === 'tags') {
setSelectedTagIds((currentTags) =>
currentTags.includes(value)
? currentTags.filter((id) => id !== value)
: [...currentTags, value],
)
}
if (key === 'subject') {
setSubject(value)
}
if (key === 'description') {
setDescription(value)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
className="p-0"
closable={false}
isOpen={isOpen}
lockScroll
width={650}
onClose={() => setIsOpen(false)}
>
<div className="min-h-[400px] py-4">
<div className="space-y-4">
<div className="flex justify-between items-center gap-4 px-4">
<h4 className="w-full -ml-2" id={dialogTitleId}>
<SelectorWraper className="w-full max-w-full">
<SubjectEditor
className="flex-1"
value={subject}
onValueChange={(value) =>
handleMutate({ key: 'subject', value })
}
/>
</SelectorWraper>
</h4>
<div>
<Button
aria-label="Close task details"
icon={<PiX />}
size="sm"
variant="ghost"
onClick={() => setIsOpen(false)}
/>
</div>
</div>
<div className="px-4">
<Field label="Ticket" icon={<PiBookmarkSimple />}>
<SelectorWraper editable={false}>OPS-482</SelectorWraper>
</Field>
<Field label="Priority" icon={<PiArrowsDownUp />}>
<PrioritySelector
value={priority}
list={priorityOptions}
onValueChange={(value) =>
handleMutate({ key: 'priority', value })
}
>
<SelectorWraper>
<Tag
className="gap-1 inline-flex items-center bg-transparent py-0.5 px-1.5"
prefix={
<span
className={classNames(
'h-2.5 w-2.5 rounded-xs',
priorities.find(
(item) => item.value === priority,
)?.dotClass,
)}
></span>
}
>
{priority}
</Tag>
</SelectorWraper>
</PrioritySelector>
</Field>
<Field label="Due date" icon={<PiClock />}>
<DuedateSelector
value={dueDate}
onValueChange={(value) =>
handleMutate({ key: 'dueDate', value })
}
>
<SelectorWraper>
{dayjs(dueDate).format('DD MMM YYYY')}
</SelectorWraper>
</DuedateSelector>
</Field>
<Field label="Assigned to" icon={<PiUser />}>
<AssigneeSelector
list={assigneeOptions}
value={assigneeIds}
onValueChange={(value) =>
handleMutate({ key: 'assignee', value })
}
>
<SelectorWraper>
{assigneeIds.length > 1 ? (
<UsersAvatarGroup
users={assigneeIds.map(getMember)}
avatarProps={{ size: 25 }}
/>
) : (
<>
{assigneeIds.map((id) => {
const member = getMember(id)
return (
<div
key={member.id}
className="flex items-center gap-1"
>
<Avatar
size={22}
src={member.img}
alt={member.name}
/>
<div>{member.name}</div>
</div>
)
})}
</>
)}
</SelectorWraper>
</AssigneeSelector>
</Field>
<Field label="Tags" icon={<PiTag />}>
<TagsSelector
list={tagsList}
value={selectedTagIds}
onValueChange={(value) =>
handleMutate({ key: 'tags', value })
}
>
<SelectorWraper wrap>
{selectedTagIds.map((tagId) => (
<Tag
className="py-0.5 px-1.5"
key={tagId}
>
{tagOptions.find(
(tag) => tag.id === tagId,
)?.label ?? tagId}
</Tag>
))}
</SelectorWraper>
</TagsSelector>
</Field>
</div>
<div className="px-4">
{isEditingDescription ? (
<>
<h6 className="mb-1">Description</h6>
<div onClick={(event) => event.stopPropagation()}>
<RichTextEditor
content={editedDescription}
onChange={({ html }) =>
setEditedDescription(html)
}
/>
<div className="flex justify-end gap-2 mt-3">
<Button
size="sm"
onClick={() => {
setIsEditingDescription(false)
setEditedDescription('')
}}
>
Cancel
</Button>
<Button
size="sm"
variant="solid"
onClick={() => {
handleMutate({
key: 'description',
value: editedDescription,
})
setIsEditingDescription(false)
}}
>
Save
</Button>
</div>
</div>
</>
) : (
<Card
className={classNames(
'bg-accent',
!isEditingDescription &&
'cursor-pointer hover:ring-1 hover:ring-border transition-all',
)}
onClick={() => {
if (!isEditingDescription) {
setEditedDescription(description)
setIsEditingDescription(true)
}
}}
>
<h6 className="mb-1">Description</h6>
<p className={classNames(!description && 'italic')}>
{description || 'Click to add description...'}
</p>
</Card>
)}
</div>
<div>
<Tabs defaultValue="comments">
<Tabs.TabList className="px-4">
<Tabs.TabNav value="comments">Comments</Tabs.TabNav>
<Tabs.TabNav value="subtasks">Subtasks</Tabs.TabNav>
<Tabs.TabNav value="attachments">Attachments</Tabs.TabNav>
</Tabs.TabList>
<Scroll.FlexSize className="max-h-[600px]">
<div className="py-4 px-6">
<Tabs.TabContent value="comments">
<div>
{comments.map((comment, index) => (
<Fragment key={comment.id}>
<div className="flex gap-2 py-4">
<div>
<Avatar
src={comment.author.img}
size={30}
/>
</div>
<div className="rounded-sm">
<div className="flex items-center mb-1">
<span className="font-medium">
{comment.author.name}
</span>
<span className="mx-1"> • </span>
<span className="text-xs">
{dayjs(
comment.createdAt,
).format('hh:mm A')}
</span>
</div>
<div className="mb-0 prose text-sm prose-p:text-sm prose-p:leading-normal [--tw-prose-body:var(--nui-muted-foreground)]">
{comment.message}
</div>
</div>
</div>
{index !== comments.length - 1 && <Divider />}
</Fragment>
))}
{comments.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiChatCircleSlash className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No comment yet</h5>
<p className="max-w-[400px]">
Be the first one to comment
</p>
</div>
</EmptyState>
</div>
)}
<div className="mt-8">
<CommentInput
onSubmit={handleCommentSubmit}
/>
</div>
</div>
</Tabs.TabContent>
<Tabs.TabContent value="subtasks">
{subtasks.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiCheckSquare className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No subtasks</h5>
</div>
</EmptyState>
</div>
)}
<div>
{subtasks.map((subtask, index) => (
<Fragment key={subtask.id}>
<div
className="py-2 px-2 flex items-center justify-between cursor-pointer"
tabIndex={0}
role="button"
onClick={() =>
handleSubTaskCheck(
subtask.id,
!subtask.completed,
)
}
>
<div className="flex items-center gap-2">
<Checkbox checked={subtask.completed} />
<div
className={classNames(
'font-medium leading-none',
subtask.completed && 'line-through',
)}
>
{subtask.title}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<UsersAvatarGroup
avatarProps={{ size: 22 }}
users={subtask.assigneeIds.map(getMember)}
/>
</div>
<div className="flex items-center gap-1 min-w-[60px] font-medium">
<PiCalendarBlank className="text-lg" />
<span className="text-xs">
{dayjs(subtask.dueDate).format('MMM DD')}
</span>
</div>
</div>
</div>
{index !== subtasks.length - 1 && <Divider />}
</Fragment>
))}
</div>
</Tabs.TabContent>
<Tabs.TabContent value="attachments">
{attachments.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center mb-4">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiFileText className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No attachments</h5>
</div>
</EmptyState>
</div>
) : (
<Card bodyClass="p-0 divide-y divide-border">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex justify-between items-center p-2"
>
<div className="flex items-center gap-2">
<FileIcon
type={attachment.type}
size={25}
/>
<div className="font-medium">
{attachment.name}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<PiDownloadSimple />}
/>
</div>
</div>
))}
</Card>
)}
</Tabs.TabContent>
</div>
</Scroll.FlexSize>
</Tabs>
</div>
<div className="flex justify-end items-center gap-2 px-4">
<Button onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="solid" loading={isSaving} onClick={handleSave}>
Save
</Button>
</div>
</div>
</div>
</Dialog>
</main>
)
}
Dialog 13
Preview
npx nateui@latest add DialogAccountSettingsDark
import { useState } from 'react'
import type { KeyboardEvent } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Dialog from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import Menu from '@/components/ui/Menu'
import Select from '@/components/ui/Select'
import Switcher from '@/components/ui/Switcher'
import IconFrame from '@/components/composites/IconFrame'
import PasswordInput from '@/components/composites/PasswordInput'
import {
PiCheck,
PiCircleDashed,
PiFingerprint,
PiLightning,
PiPencilSimple,
PiStack,
PiUserCircleCheck,
PiWallet,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-account-settings-title'
type SettingsSection =
| 'general'
| 'profile'
| 'security'
| 'notifications'
| 'subscription'
type EditableField =
| 'username'
| 'profileUrl'
| 'displayName'
| 'password'
type SelectOption = {
label: string
value: string
}
const sectionItems = [
{ eventKey: 'general' as const, label: 'General', icon: PiCircleDashed },
{ eventKey: 'profile' as const, label: 'Profile', icon: PiUserCircleCheck },
{ eventKey: 'security' as const, label: 'Security', icon: PiFingerprint },
{
eventKey: 'notifications' as const,
label: 'Notifications',
icon: PiLightning,
},
{
eventKey: 'subscription' as const,
label: 'Subscription',
icon: PiWallet,
},
]
const languageOptions: SelectOption[] = [
{ label: 'English', value: 'english' },
{ label: 'French', value: 'french' },
{ label: 'Japanese', value: 'japanese' },
]
const notificationOptions: SelectOption[] = [
{ label: 'Push', value: 'push' },
{ label: 'Email', value: 'email' },
{ label: 'None', value: 'none' },
]
export default function DialogAccountSettings() {
const [isOpen, setIsOpen] = useState(true)
const [activeSection, setActiveSection] =
useState<SettingsSection>('general')
const [editingField, setEditingField] = useState<EditableField | null>(null)
const [draftValue, setDraftValue] = useState('')
const [username, setUsername] = useState('rae')
const [profileUrl, setProfileUrl] = useState('northstar.design/rae')
const [displayName, setDisplayName] = useState('Rae Delgado')
const [password, setPassword] = useState('N8te!Settings')
const [autoSave, setAutoSave] = useState(true)
const [keyboardShortcuts, setKeyboardShortcuts] = useState(true)
const [compactNavigation, setCompactNavigation] = useState(false)
const [language, setLanguage] = useState(languageOptions[0])
const [profileVisibility, setProfileVisibility] = useState(false)
const [mfa, setMfa] = useState(false)
const [notificationStyle, setNotificationStyle] = useState(
notificationOptions[0],
)
const [exportFinished, setExportFinished] = useState(true)
const [weeklySummary, setWeeklySummary] = useState(false)
const [commentReplies, setCommentReplies] = useState(true)
const beginEdit = (field: EditableField, value: string) => {
setEditingField(field)
setDraftValue(value)
}
const commitEdit = () => {
if (!editingField) {
return
}
switch (editingField) {
case 'username':
setUsername(draftValue)
break
case 'profileUrl':
setProfileUrl(draftValue)
break
case 'displayName':
setDisplayName(draftValue)
break
case 'password':
setPassword(draftValue)
break
}
setEditingField(null)
}
const revertEdit = () => {
setEditingField(null)
}
const renderEditableRow = (
field: EditableField,
label: string,
value: string,
options: { password?: boolean } = {},
) => {
const isEditing = editingField === field
const restingValue = options.password ? '••••••••••••' : value
return (
<div className="flex min-h-9 items-center justify-between gap-4 px-4 py-4">
<p className="min-w-0 flex-1 font-medium text-foreground">{label}</p>
<div className="flex min-h-9 w-1/2 min-w-0 shrink-0 items-center justify-end">
{isEditing ? (
<div
className="flex min-w-0 flex-1 items-center justify-end gap-2"
onBlur={(event) => {
if (
!event.currentTarget.contains(
event.relatedTarget as Node | null,
)
) {
revertEdit()
}
}}
>
{options.password ? (
<PasswordInput
aria-label={`${label} value`}
autoFocus
className="min-w-0 flex-1"
size="sm"
value={draftValue}
onChange={(event) =>
setDraftValue(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitEdit()
}
if (event.key === 'Escape') {
event.preventDefault()
revertEdit()
}
}}
/>
) : (
<Input
aria-label={`${label} value`}
autoFocus
className="min-w-0 flex-1"
size="sm"
value={draftValue}
onChange={(event) =>
setDraftValue(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitEdit()
}
if (event.key === 'Escape') {
event.preventDefault()
revertEdit()
}
}}
/>
)}
<Button
aria-label={`Save ${label.toLowerCase()}`}
icon={<PiCheck aria-hidden="true" />}
shape="circle"
size="sm"
type="button"
variant="ghost"
onClick={commitEdit}
/>
</div>
) : (
<button
aria-label={`Edit ${label.toLowerCase()}`}
className="inline-flex min-w-0 max-w-full items-center gap-2 rounded-control px-2 py-1 text-start font-medium text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
type="button"
onClick={() => beginEdit(field, value)}
>
<span className="min-w-0 truncate">{restingValue}</span>
<PiPencilSimple
aria-hidden="true"
className="shrink-0 text-lg"
/>
</button>
)}
</div>
</div>
)
}
const renderSwitcherRow = (
label: string,
checked: boolean,
onChange: (checked: boolean) => void,
helper?: string,
) => (
<div className="flex min-h-9 items-center justify-between gap-4 px-4 py-4">
<div className="min-w-0">
<p className="font-medium text-foreground">{label}</p>
{helper && <p className="text-muted-foreground">{helper}</p>}
</div>
<Switcher
aria-label={label}
checked={checked}
onChange={onChange}
/>
</div>
)
const renderSelectRow = (
label: string,
value: SelectOption,
options: SelectOption[],
onChange: (option: SelectOption) => void,
) => (
<div className="flex min-h-9 flex-col justify-center gap-2 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<p className="font-medium text-foreground">{label}</p>
<Select
aria-label={label}
className="w-full sm:w-48"
options={options}
value={value}
onChange={(option) => onChange(option as SelectOption)}
/>
</div>
)
const renderGeneralPanel = () => (
<div className="divide-y">
{renderEditableRow('username', 'Username', username)}
<div className="flex min-h-9 items-center justify-between gap-4 px-4 py-4">
<p className="font-medium text-foreground">Email</p>
<span className="min-w-0 truncate">
rae@northstar.test
</span>
</div>
{renderSwitcherRow('Save drafts automatically', autoSave, setAutoSave)}
{renderSwitcherRow(
'Show keyboard shortcuts',
keyboardShortcuts,
setKeyboardShortcuts,
)}
{renderSwitcherRow(
'Compact navigation',
compactNavigation,
setCompactNavigation,
)}
{renderSelectRow('Language', language, languageOptions, setLanguage)}
</div>
)
const renderProfilePanel = () => (
<div className="divide-y">
{renderSwitcherRow(
'Profile visibility',
profileVisibility,
setProfileVisibility,
)}
<div className="flex min-h-9 items-center justify-between gap-4 px-4 py-4">
<div className="min-w-0">
<p className="font-medium text-foreground">Avatar</p>
</div>
<Avatar
alt="Profile avatar"
size={48}
src={`${assetBase}/avatars/thumb-3.jpg`}
/>
</div>
{renderEditableRow('profileUrl', 'Public profile URL', profileUrl)}
{renderEditableRow('displayName', 'Display name', displayName)}
</div>
)
const renderSecurityPanel = () => (
<div className="divide-y">
{renderEditableRow('password', 'Password', password, {
password: true,
})}
{renderSwitcherRow(
'Multi-factor authentication',
mfa,
setMfa,
)}
<div className="flex min-h-9 items-center justify-between gap-4 px-4 py-4">
<div className="min-w-0">
<p className="font-medium text-foreground">
Sign out of all devices
</p>
</div>
<Button type="button">Sign out all</Button>
</div>
</div>
)
const renderNotificationsPanel = () => (
<div className="divide-y">
{renderSelectRow(
'Notification style',
notificationStyle,
notificationOptions,
setNotificationStyle,
)}
{renderSwitcherRow('Export finished', exportFinished, setExportFinished)}
{renderSwitcherRow('Weekly activity summary', weeklySummary, setWeeklySummary)}
{renderSwitcherRow(
'Replies to your comments',
commentReplies,
setCommentReplies,
)}
</div>
)
const renderSubscriptionPanel = () => (
<div className="px-4 py-4">
<p className="text-foreground">
Next billing date is September 3, 2026
</p>
<Card className="mt-4" bodyClass="p-2">
<div className="bg-muted border rounded-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-2">
<IconFrame size={40}>
<img
alt=""
className="h-6 w-6 object-contain"
src={`${assetBase}/thumbs/plans/pro.svg`}
/>
</IconFrame>
<div className="min-w-0">
<p className="font-medium text-foreground">
Growth plan
</p>
<p className="text-muted-foreground">
For teams that outgrew a single seat.
</p>
</div>
</div>
<div className="shrink-0 text-end">
<p className="font-semibold text-foreground">
$20.00
</p>
<p className="text-muted-foreground">
Billed monthly
</p>
</div>
</div>
</div>
<Button
block
className="mt-4"
type="button"
variant="solid"
>
Upgrade
</Button>
<ul className="mt-4 flex flex-col gap-2 p-2" aria-label="Included features">
{[
'Unlimited projects',
'Shared workspaces',
'Priority exports',
'Version history',
'Email support',
].map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="size-6 rounded-lg bg-muted flex items-center justify-center">
<PiCheck aria-hidden="true" className="shrink-0" />
</span>
<span>{feature}</span>
</li>
))}
</ul>
</Card>
</div>
)
const panelContent = {
general: renderGeneralPanel,
profile: renderProfilePanel,
security: renderSecurityPanel,
notifications: renderNotificationsPanel,
subscription: renderSubscriptionPanel,
}[activeSection]()
const activeLabel = sectionItems.find(
(item) => item.eventKey === activeSection,
)?.label
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-labelledby={dialogTitleId}
className="p-0"
isOpen={isOpen}
lockScroll
width={700}
onClose={() => setIsOpen(false)}
>
<div className="flex h-[540px] flex-col md:flex-row">
<nav
aria-label="Account settings sections"
className="shrink-0 border-b p-4 md:w-52 md:border-b-0 md:border-r"
>
<Menu variant="subtle">
{sectionItems.map((item) => {
const isActive = activeSection === item.eventKey
const Icon = item.icon
return (
<Menu.MenuItem
key={item.eventKey}
aria-current={
isActive ? 'page' : undefined
}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
eventKey={item.eventKey}
isActive={isActive}
role="button"
tabIndex={0}
onClick={() =>
setActiveSection(item.eventKey)
}
onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
if (
event.key === 'Enter' ||
event.key === ' '
) {
event.preventDefault()
setActiveSection(item.eventKey)
}
}}
>
<Icon
aria-hidden="true"
className="text-lg"
/>
<span>{item.label}</span>
</Menu.MenuItem>
)
})}
</Menu>
</nav>
<section className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<header className="border-b p-4 pr-12">
<h5
className="text-lg font-semibold text-foreground"
id={dialogTitleId}
>
{activeLabel}
</h5>
</header>
{panelContent}
</section>
</div>
</Dialog>
</main>
)
}
Dialog 14
Preview
npx nateui@latest add DialogAddIntegrationsDark
import { useMemo, useState } from 'react'
import {
PiArrowsClockwise,
PiCheck,
PiDatabase,
PiMagnifyingGlass,
PiPlus,
} from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Dialog from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import Scroll from '@/components/ui/Scroll'
import InputGroup from '@/components/ui/InputGroup'
import EmptyState from '@/components/composites/EmptyState'
import IconFrame from '@/components/composites/IconFrame'
const assetBase = 'https://statics.nateui.com/img'
const dialogTitleId = 'dialog-add-integrations-title'
const dialogDescriptionId = 'dialog-add-integrations-description'
type IntegrationType = 'automation' | 'storageCollaboration'
type FilterKey = 'all' | IntegrationType
type Integration = {
id: string
name: string
desc: string
logo: string
type: IntegrationType
active: boolean
}
const initialIntegrations: Integration[] = [
{
id: 'google-drive',
name: 'Google Drive',
desc: 'Upload your files to Google Drive',
logo: `${assetBase}/thumbs/brands/google-drive.png`,
type: 'storageCollaboration',
active: true,
},
{
id: 'github',
name: 'GitHub',
desc: 'Exchange files with a GitHub repository',
logo: `${assetBase}/thumbs/brands/github.png`,
type: 'storageCollaboration',
active: true,
},
{
id: 'zapier',
name: 'Zapier',
desc: 'Integrate with hundreds of services.',
logo: `${assetBase}/thumbs/brands/zapier.png`,
type: 'automation',
active: false,
},
{
id: 'make',
name: 'Make (Integromat)',
desc: 'Visually automate your workflows with Make',
logo: `${assetBase}/thumbs/brands/make.png`,
type: 'automation',
active: false,
},
{
id: 'pabbly',
name: 'Pabbly Connect',
desc: 'Affordable automation for SaaS and CRM tools',
logo: `${assetBase}/thumbs/brands/pabbly.png`,
type: 'automation',
active: false,
},
{
id: 'slack',
name: 'Slack',
desc: 'Post to a Slack channel',
logo: `${assetBase}/thumbs/brands/slack.png`,
type: 'storageCollaboration',
active: false,
},
{
id: 'notion',
name: 'Notion',
desc: 'Retrieve notion note to your project',
logo: `${assetBase}/thumbs/brands/notion.png`,
type: 'storageCollaboration',
active: false,
},
{
id: 'dropbox',
name: 'Dropbox',
desc: 'Exchange data with Dropbox',
logo: `${assetBase}/thumbs/brands/dropbox.png`,
type: 'storageCollaboration',
active: false,
},
{
id: 'hubspot',
name: 'HubSpot',
desc: 'Sync contacts and activity with HubSpot',
logo: `${assetBase}/thumbs/brands/hubspot.png`,
type: 'automation',
active: false,
},
{
id: 'salesforce',
name: 'Salesforce',
desc: 'Send records to Salesforce',
logo: `${assetBase}/thumbs/brands/salesforce.png`,
type: 'automation',
active: false,
},
{
id: 'asana',
name: 'Asana',
desc: 'Create tasks from new work items',
logo: `${assetBase}/thumbs/brands/asana.png`,
type: 'automation',
active: false,
},
{
id: 'jira',
name: 'Jira',
desc: 'Link issues from a Jira project',
logo: `${assetBase}/thumbs/brands/jira.png`,
type: 'automation',
active: false,
},
]
const filterOptions: Array<{
key: FilterKey
label: string
icon?: typeof PiArrowsClockwise
}> = [
{ key: 'all', label: 'All' },
{ key: 'automation', label: 'Automation', icon: PiArrowsClockwise },
{
key: 'storageCollaboration',
label: 'Storage',
icon: PiDatabase,
},
]
export default function DialogAddIntegrations() {
const [isOpen, setIsOpen] = useState(true)
const [activeFilter, setActiveFilter] = useState<FilterKey>('all')
const [query, setQuery] = useState('')
const [connectedIds, setConnectedIds] = useState<string[]>(() =>
initialIntegrations
.filter((integration) => integration.active)
.map((integration) => integration.id),
)
const filteredIntegrations = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return initialIntegrations.filter((integration) => {
const matchesFilter =
activeFilter === 'all' || integration.type === activeFilter
const matchesQuery =
!normalizedQuery ||
integration.name.toLowerCase().includes(normalizedQuery) ||
integration.desc.toLowerCase().includes(normalizedQuery)
return matchesFilter && matchesQuery
})
}, [activeFilter, query])
const toggleConnection = (id: string) => {
setConnectedIds((current) =>
current.includes(id)
? current.filter((connectedId) => connectedId !== id)
: [...current, id],
)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open dialog
</Button>
<Dialog
aria-describedby={dialogDescriptionId}
aria-labelledby={dialogTitleId}
className="p-0"
closable={false}
isOpen={isOpen}
lockScroll
onClose={() => setIsOpen(false)}
width={680}
>
<header className="flex justify-between border-b px-4 py-4">
<div>
<h5
className="text-lg font-semibold text-foreground"
id={dialogTitleId}
>
Add an integration
</h5>
<p
className="text-muted-foreground"
id={dialogDescriptionId}
>
Connect the tools your workspace uses.
</p>
</div>
<div>
<CloseButton onClick={() => setIsOpen(false)} />
</div>
</header>
<section
aria-label="Integration filters"
className="flex flex-col gap-4 border-b px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<InputGroup>
{filterOptions.map((option) => {
const Icon = option.icon
const isActive = activeFilter === option.key
return (
<Button
key={option.key}
icon={
Icon ? (
<Icon aria-hidden="true" />
) : undefined
}
iconAlignment="start"
active={isActive}
onClick={() => setActiveFilter(option.key)}
>
{option.label}
</Button>
)
})}
</InputGroup>
<div className="w-full sm:max-w-55">
<Input
aria-label="Search integrations"
placeholder="Search integrations"
prefix={<PiMagnifyingGlass aria-hidden="true" />}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</section>
<section aria-label="Available integrations" className="">
<Scroll.FlexSize className="max-h-[420px]">
<div className="divide-y">
{filteredIntegrations.length > 0 ? (
filteredIntegrations.map((integration) => {
const isConnected = connectedIds.includes(
integration.id,
)
return (
<div
key={integration.id}
className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center"
>
<Avatar
alt={`${integration.name} logo`}
className="shrink-0 border bg-white p-1"
shape="round"
size={40}
src={integration.logo}
/>
<div className="min-w-0 flex-1">
<p className="font-semibold text-card-foreground">
{integration.name}
</p>
<p className="text-muted-foreground">
{integration.desc}
</p>
</div>
<Button
className="self-start sm:self-auto"
icon={
isConnected ? (
<PiCheck aria-hidden="true" />
) : (
<PiPlus aria-hidden="true" />
)
}
iconAlignment="start"
size="sm"
variant={
isConnected ? 'subtle' : 'default'
}
onClick={() =>
toggleConnection(integration.id)
}
>
{isConnected ? 'Connected' : 'Connect'}
</Button>
</div>
)
})
) : (
<EmptyState
variant="dots"
illustration={
<IconFrame>
<PiMagnifyingGlass
aria-hidden="true"
className="text-xl"
/>
</IconFrame>
}
size={160}
>
<div className="text-center mb-4">
<h6 className="text-base font-medium text-foreground">
No integrations found
</h6>
<p className="mt-1 text-center text-muted-foreground">
Try a different filter or search term.
</p>
</div>
</EmptyState>
)}
</div>
</Scroll.FlexSize>
</section>
<footer className="flex flex-col-reverse gap-2 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-end">
<Button
className="w-full sm:flex-1"
type="button"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
className="w-full sm:flex-1"
type="button"
variant="solid"
onClick={() => setIsOpen(false)}
>
Save Changes
</Button>
</footer>
</Dialog>
</main>
)
}