GenCN UI

AI Sonner / Toast

AI-powered toast notifications using Sonner with automatic text rewriting and tone customization.

AI Sonner provides AI-powered toast notifications using Sonner with automatic text rewriting and tone customization capabilities.

Try out the component below to see how AI-powered toast notifications work with automatic text rewriting and tone customization.

Loading preview...
"use client";

import * as React from "react";
import {
  GencnUISonner,
  useGencnUIToast,
  Toaster
} from "@/registry/new-york/gencn-ui/items/sonner/gencn-ui-sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { AITone } from "@/registry/new-york/gencn-ui/items/shared/gencn-ui-types";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";

// Array of AITone values for runtime use
const AITONE_OPTIONS: AITone[] = [
  "friendly",
  "funny",
  "sarcastic",
  "rude",
  "dramatic",
  "professional",
  "cheerful",
  "empathetic",
  "concise",
  "motivational",
];

function GencnUISonnerExampleContent() {
  const [message, setMessage] = React.useState(
    "Your form is saved successfully!"
  );
  const [tone, setTone] = React.useState<AITone | undefined>(undefined);
  const [toastType, setToastType] = React.useState<
    "success" | "error" | "info" | "warning"
  >("success");

  const gencnUIToast = useGencnUIToast();

  const handleToast = async () => {
    if (!message.trim()) return;

    const options = tone ? { tone } : undefined;

    switch (toastType) {
      case "success":
        await gencnUIToast.success(message, options);
        break;
      case "error":
        await gencnUIToast.error(message, options);
        break;
      case "info":
        await gencnUIToast.info(message, options);
        break;
      case "warning":
        await gencnUIToast.warning(message, options);
        break;
    }
  };

  return (
    <>
      <Toaster richColors duration={10000} />
      <Card className="m-0 w-full gap-0 border-none p-0 shadow-none">
        <CardHeader className="pb-3">
          <CardTitle className="text-lg">GencnUI Sonner Example</CardTitle>
        </CardHeader>
        <CardContent className="space-y-3">
          <div className="space-y-1.5">
            <Label htmlFor="toast-message" className="text-xs">
              Edit Message
            </Label>
            <Input
              id="toast-message"
              value={message}
              onChange={(e) => setMessage(e.target.value)}
              placeholder="Enter your message..."
              className="h-9 w-full"
            />
          </div>

          <div className="space-y-3">
            <div className="space-y-1.5">
              <Label htmlFor="toast-tone" className="text-xs">
                Tone
              </Label>
              <Select
                value={tone}
                onValueChange={(value) => setTone(value as AITone)}
              >
                <SelectTrigger id="toast-tone" className="h-9 w-full">
                  <SelectValue placeholder="Default" />
                </SelectTrigger>
                <SelectContent>
                  {AITONE_OPTIONS.map((toneOption) => (
                    <SelectItem key={toneOption} value={toneOption}>
                      {toneOption.charAt(0).toUpperCase() + toneOption.slice(1)}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="toast-type" className="text-xs">
                Type
              </Label>
              <Select
                value={toastType}
                onValueChange={(value) =>
                  setToastType(value as typeof toastType)
                }
              >
                <SelectTrigger id="toast-type" className="h-9 w-full">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="success">Success</SelectItem>
                  <SelectItem value="error">Error</SelectItem>
                  <SelectItem value="info">Info</SelectItem>
                  <SelectItem value="warning">Warning</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <Button
              onClick={handleToast}
              disabled={!message.trim()}
              variant={toastType === "error" ? "destructive" : "default"}
              className="h-9 w-full px-4 text-xs"
            >
              Show Toast
            </Button>
          </div>
          {tone && (
            <Button
              variant="ghost"
              size="sm"
              onClick={() => setTone(undefined)}
              className="-mt-2 h-6 text-xs"
            >
              Clear tone
            </Button>
          )}
        </CardContent>
      </Card>
    </>
  );
}

export function GencnUISonnerExample() {
  return (
    <GencnUISonner>
      <GencnUISonnerExampleContent />
    </GencnUISonner>
  );
}

Server API

Path:
/api/improve
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface ImproveRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(): string {
  return `You are a helpful writing assistant. Improve the given text for clarity and readability while preserving its original meaning and intent.`;
}

export async function POST(req: Request) {
  try {
    const request: ImproveRequest = await req.json();
    const {
      context,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const text = request.text || "";
    const systemPrompt = buildSystemPrompt();

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${text}` : text;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Text is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Improve API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Installation

Setup Required: Make sure you have configured components.json first. See the installation guide for setup instructions.

npx shadcn@latest add @gencn-ui/gencn-ui-sonner
"use client";

import * as React from "react";
import { toast, type ExternalToast } from "sonner";
import { useRewriter } from "@/registry/new-york/gencn-ui/items/shared/hooks/internal/use-gencn-ui-rewriter";
import type { AITone } from "@/registry/new-york/gencn-ui/items/shared/gencn-ui-types";

export type { AITone };

export type GencnUISONNEROptions = ExternalToast & {
  tone?: AITone;
  maxLength?: number;
  retryOnFail?: boolean;
};

interface GencnUISonnerContextValue {
  rewriteMessage: (
    message: React.ReactNode,
    tone?: AITone,
    maxLength?: number,
    retryOnFail?: boolean
  ) => Promise<React.ReactNode>;
}

const GencnUISonnerContext = React.createContext<
  GencnUISonnerContextValue | undefined
>(undefined);

export interface GencnUISonnerProps {
  children: React.ReactNode;
  defaultRewriterOptions?: {
    sharedContext?: string;
  };
}

export function GencnUISonner({
  children,
  defaultRewriterOptions,
}: GencnUISonnerProps) {
  // Call useRewriter with options, similar to how useChat is called
  const rewriter = useRewriter();

  const rewriteMessage = React.useCallback(
    async (
      message: React.ReactNode,
      tone?: AITone,
      maxLength?: number,
      retryOnFail: boolean = true
    ): Promise<React.ReactNode> => {
      if (typeof message !== "string") return message;

      const text = message as string;
      const toneInstruction = tone
        ? `Rephrase the following text in a ${tone} tone while preserving intent.`
        : "Rephrase the following text to improve clarity while preserving intent.";

      const lengthInstruction =
        typeof maxLength === "number" && maxLength > 0
          ? ` Keep it under ${maxLength} characters.`
          : "";

      const context = `${toneInstruction}${lengthInstruction} Respond with only the rewritten text, no quotes.`;

      try {
        // Use rewriter hook's generate function which handles downloadable state and server fallback automatically
        // If rewriter is downloadable, it will register the download and return the same text
        // If rewriter is unavailable, it will use server fallback if enabled
        const rewritten = await rewriter.generate({
          text,
          tone,
          context: defaultRewriterOptions?.sharedContext
            ? `${defaultRewriterOptions.sharedContext}\n\n${context}`
            : context,
          streaming: false,
        });
        return rewritten || text;
      } catch (err) {
        if (retryOnFail) return text;
        throw err;
      }
    },
    [rewriter, defaultRewriterOptions]
  );

  const value: GencnUISonnerContextValue = React.useMemo(
    () => ({
      rewriteMessage,
    }),
    [rewriteMessage]
  );

  return (
    <GencnUISonnerContext.Provider value={value}>
      {children}
    </GencnUISonnerContext.Provider>
  );
}

export function useGencnUISonner(): GencnUISonnerContextValue {
  const context = React.useContext(GencnUISonnerContext);
  if (context === undefined) {
    throw new Error(
      "useGencnUISonner must be used within a GencnUISonner component"
    );
  }
  return context;
}

async function rewriteIfString(
  input: React.ReactNode,
  rewriteMessage: GencnUISonnerContextValue["rewriteMessage"],
  tone?: AITone,
  maxLength?: number,
  retryOnFail: boolean = true
): Promise<React.ReactNode> {
  if (typeof input !== "string") return input;
  return rewriteMessage(input, tone, maxLength, retryOnFail);
}

export function createGencnUIToast(
  rewriteMessage: GencnUISonnerContextValue["rewriteMessage"]
) {
  const gencnUIToast = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast(rewritten, options);
  };

  gencnUIToast.success = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast.success(rewritten, options);
  };

  gencnUIToast.error = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast.error(rewritten, options);
  };

  gencnUIToast.info = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast.info(rewritten, options);
  };

  gencnUIToast.warning = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast.warning(rewritten, options);
  };

  gencnUIToast.message = async function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    const rewritten = await rewriteIfString(
      message,
      rewriteMessage,
      options?.tone,
      options?.maxLength,
      options?.retryOnFail !== false
    );
    return toast.message(rewritten as any, options as any);
  };

  gencnUIToast.loading = function (
    message: React.ReactNode,
    options?: GencnUISONNEROptions
  ) {
    // For loading, show immediately; do not wait for rewriting to avoid UX delay.
    return toast.loading(message, options);
  };

  gencnUIToast.custom = function (
    renderer: Parameters<typeof toast.custom>[0],
    options?: ExternalToast
  ) {
    return toast.custom(renderer, options);
  };

  gencnUIToast.dismiss = function (toastId?: number | string) {
    return toast.dismiss(toastId as any);
  };

  gencnUIToast.promise = async function <T>(
    promise: Promise<T>,
    cfg: {
      loading: React.ReactNode;
      success: React.ReactNode | ((value: T) => React.ReactNode);
      error: React.ReactNode | ((error: any) => React.ReactNode);
      tone?: AITone;
      maxLength?: number;
      retryOnFail?: boolean;
    },
    options?: GencnUISONNEROptions
  ) {
    // Show loading immediately (without waiting for rewrite)
    const id = toast.loading(cfg.loading, options);

    try {
      const value = await promise;
      const successContent =
        typeof cfg.success === "function" ? cfg.success(value) : cfg.success;
      const rewritten = await rewriteIfString(
        successContent,
        rewriteMessage,
        cfg.tone ?? options?.tone,
        cfg.maxLength ?? options?.maxLength,
        (cfg.retryOnFail ?? options?.retryOnFail) !== false
      );
      return toast.success(rewritten, { ...options, id });
    } catch (error) {
      const errorContent =
        typeof cfg.error === "function" ? cfg.error(error) : cfg.error;
      const rewritten = await rewriteIfString(
        errorContent,
        rewriteMessage,
        cfg.tone ?? options?.tone,
        cfg.maxLength ?? options?.maxLength,
        (cfg.retryOnFail ?? options?.retryOnFail) !== false
      );
      return toast.error(rewritten, { ...options, id });
    }
  };

  return gencnUIToast;
}

// Hook to get toast functions
export function useGencnUIToast() {
  const { rewriteMessage } = useGencnUISonner();
  return React.useMemo(
    () => createGencnUIToast(rewriteMessage),
    [rewriteMessage]
  );
}

// Re-export Toaster for convenience
export { Toaster } from "sonner";

export type GencnUIToastType = ReturnType<typeof createGencnUIToast>;

Usage

Wrap your application with GencnUISonner component at a high level, then use the useGencnUIToast hook to access toast functions:

import {
  GencnUISonner,
  useGencnUIToast,
  Toaster,
} from "@/registry/new-york/gencn-ui/items/sonner/gencn-ui-sonner";

function App() {
  return (
    <GencnUISonner>
      <Toaster />
      <YourAppContent />
    </GencnUISonner>
  );
}

function YourAppContent() {
  const gencnUIToast = useGencnUIToast();

  const handleClick = async () => {
    await gencnUIToast.success("Form saved successfully!", {
      tone: "friendly",
    });
  };

  return <button onClick={handleClick}>Save</button>;
}

Component API