HextaUI

Notifications

The Notifications section of an AI product's settings. A channel by event grid with row, column and all toggles, quiet hours with a live next-quiet line, an email digest, real test sends for desktop, email, push and Slack, browser permission handling and a Slack connect flow. Drops into any Settings section.

Every AI product ends up with the same notifications page: a list of things that can happen, a list of places they can reach you, and a way to make it stop at night. NotificationSettings is that page as one section. It drops into a SettingsSection and uses the same draft, save bar and unsaved-changes guard as the rest of Settings.

The core is a grid of events by channels. The top row turns a channel on or off for every event, the first column turns an event on or off everywhere, and the corner checkbox does all of it. Each shows a dash when only some are on. With the keyboard the grid is one tab stop: arrow keys move between checkboxes, Home and End jump along a row, and Space toggles. Screen readers hear each one as "Email for Agent finished". On a phone the grid becomes one card per event with large channel chips.

Each channel has a Send test that really sends. Desktop and push show a notification banner over the page, and desktop also fires a real system notification once the browser allows it. Email opens a preview of the message in an inbox, the digest version when emails are bundled. Slack shows the message as your team sees it in the channel.

Desktop reads the browser's permission. When it hasn't been asked, Allow asks; when it's blocked, it says where to change it; when the browser can't show notifications, it says so. Slack starts disconnected: its column is off, and Connect opens a dialog to pick a workspace and channel. If connecting fails, the reason shows in the dialog and nothing is lost.

Quiet hours hold desktop and push alerts between two times on the days you pick, in the time zone you pick, with a line that says when the next quiet period starts or that it's quiet now. Urgent events like approvals can still come through.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/notifications

Wire it to your API

Pass the saved preferences and an async onSave. Edits stay in a draft until someone saves from the save bar or with ⌘S. Return field errors to show them under the field, or throw to show the message in the save bar. onSendTest sends a real test before the preview appears.

"use client"

import { IconBell } from "@tabler/icons-react"

import { SettingsSection, SettingsShell } from "../settings/settings"

import { NotificationSettings, type NotificationPreferences } from "@/components/blocks/notifications/notification-settings"

export function NotificationsPage({ preferences }: { preferences: NotificationPreferences }) {
  return (
    <SettingsShell
      sections={[{ id: "notifications", label: "Notifications", icon: <IconBell /> }]}
      className="h-svh"
    >
      <SettingsSection id="notifications">
        <NotificationSettings
          preferences={preferences}
          email="[email protected]"
          device="Mia’s iPhone"
          onSave={async (values) => {
            const response = await fetch("/api/notifications", {
              method: "PUT",
              body: JSON.stringify(values),
            })
            if (response.status === 422) return { quietDays: "Pick at least one day." }
            if (!response.ok) throw new Error("Check your connection and try again.")
          }}
          onSendTest={async ({ channel, event }) => {
            const response = await fetch("/api/notifications/test", {
              method: "POST",
              body: JSON.stringify({ channel, event: event.id }),
            })
            if (!response.ok) throw new Error("Couldn’t send the test.")
          }}
        />
      </SettingsSection>
    </SettingsShell>
  )
}

Your own events and channels

Pass events to add or drop rows and channels to pick columns. Each event can carry a sample for test previews and urgent to let it through quiet hours. onSlackConnect gets the chosen workspace and channel; throw to show why it failed, and the dialog stays open.

"use client"

import * as React from "react"

import {
  NotificationSettings,
  notificationEvents,
  type NotificationEvent,
  type NotificationPreferences,
  type SlackConnection,
  type SlackWorkspace,
} from "@/components/blocks/notifications/notification-settings"

const events: NotificationEvent[] = [
  ...notificationEvents.filter((event) => event.id !== "billing"),
  {
    id: "deploys",
    label: "Deploys",
    description: "An agent ships to production.",
    sample: {
      title: "Deployed to production",
      body: "“Fix flaky checkout test” is live. 2 checks passed.",
      subject: "Deployed: Fix flaky checkout test",
      action: "Open deploy",
    },
  },
]

export function TeamNotifications({
  preferences,
  workspaces,
  connection,
}: {
  preferences: NotificationPreferences
  workspaces: SlackWorkspace[]
  connection: SlackConnection | null
}) {
  const [slack, setSlack] = React.useState(connection)

  return (
    <NotificationSettings
      preferences={preferences}
      events={events}
      channels={["email", "slack"]}
      email="[email protected]"
      slack={slack}
      slackWorkspaces={workspaces}
      onSlackConnect={async ({ workspace, channel }) => {
        const response = await fetch("/api/slack/connect", {
          method: "POST",
          body: JSON.stringify({ workspace: workspace.id, channel: channel.id }),
        })
        if (response.status === 403) {
          throw new Error(`Hexta isn’t in #${channel.name}. Run /invite @Hexta there first.`)
        }
        if (!response.ok) throw new Error("Slack didn’t answer. Try again.")
        setSlack({ workspace: workspace.name, channel: channel.name })
      }}
      onSlackDisconnect={async () => {
        await fetch("/api/slack/connect", { method: "DELETE" })
        setSlack(null)
      }}
      onSave={(values) =>
        fetch("/api/notifications", { method: "PUT", body: JSON.stringify(values) }).then(() => {})
      }
    />
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
NotificationSettingsThe whole section: the event grid, channels with tests, quiet hours and the email digest.
notificationEventsThe default events: Agent finished, Needs approval, Run failed, Mentions, Weekly summary and Billing.
notificationChannelsThe default channels: desktop, email, push and slack.

NotificationSettings

Must be inside a SettingsSection.

PropTypeDefault
preferencesWhat's saved now: { matrix, quietHours, quietFrom, quietTo, quietDays, timeZone, quietUrgent, digest }. matrix maps each event id to the channels that are on. When it changes and there are no edits, the draft follows it.
NotificationPreferences–
onSaveSave the draft. Return { quietDays: "…" } and the like to show field errors, or throw to show the message in the save bar.
(preferences) => void | errors | Promise<void | errors>–
eventsRows of the grid: { id, label, description?, urgent?, sample? }. sample is { title, body, subject, action } for test previews. urgent events can come through quiet hours.
NotificationEvent[]notificationEvents
channelsColumns of the grid and rows under Channels, in order. Any of "desktop", "email", "push" and "slack".
NotificationChannel[]notificationChannels
emailWhere email goes. Shown under Email and in the test email.
string"[email protected]"
deviceThe phone that gets push notifications.
string"your phone"
slackThe connected Slack channel. While it's null, the Slack column is off.
{ workspace, channel } | nullnull
slackWorkspacesWorkspaces to pick from when connecting: { id, name, channels: { id, name, private? }[] }.
SlackWorkspace[][]
onSlackConnectConnect the chosen channel. Throw to show the message in the dialog.
({ workspace, channel }) => void | Promise<void>–
onSlackDisconnectDisconnect Slack after the person confirms.
() => void | Promise<void>–
onSendTestSend a real test. The preview shows once it resolves, and the button offers a retry if it throws. event is the first one that's on for that channel.
({ channel, event }) => void | Promise<void>–
KeyAction
TabMoves into the grid once, to the last checkbox you used, then on to the channels.
↑↓←→In the grid, move between checkboxes. Left and right follow reading direction.
HomeEndIn the grid, jump to the start or end of the row. With Ctrl or ⌘, to the first or last checkbox.
SpaceTurns the focused checkbox on or off. On a row, column or corner checkbox, turns all of them on, or off when all are on.
EscDismisses a test notification banner while it has focus.
  • The grid is an ARIA grid with column and row headers, and every checkbox is named by its channel and event, like "Mobile push for Run failed".
  • Row, column and corner checkboxes are mixed when only some are on, so screen readers hear "partially checked".
  • Test sends, permission results and Slack changes are announced politely. The next quiet period is a status line that updates as you edit.
  • Test banners pause while hovered or focused, have a dismiss button, and fade instead of sliding with reduced motion.
  • On phones every channel chip is a 44px target.

Code

5 files, added to components/blocks/notifications.