Polite Refusal New
A calm way to decline a request: a plain reason, alternatives the assistant can do instead, and a learn-more link.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-refusal-message.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/errors/RefusalMessage.tsx
import { useState } from 'react';
import { ArrowUpRight, ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
import { AssistantMsg, Composer, Thread, UserMsg } from './shared';
export type RefusalMessageProps = {
title?: string;
reason: string;
alternatives: string[];
onPick?: (alt: string) => void;
learnMoreHref?: string;
className?: string;
};
/** A calm, non-alarming way to decline and steer toward something useful. */
export function RefusalMessage({ title = "I can't help with that part", reason, alternatives, onPick, learnMoreHref, className }: RefusalMessageProps) {
return (
<div className={cn('max-w-[95%] space-y-3', className)}>
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/5 text-ink-300 shadow-hairline">
<ShieldCheck className="h-3.5 w-3.5" />
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-ink-100">{title}</p>
<p className="mt-1 text-sm leading-relaxed text-ink-400">{reason}</p>
</div>
</div>
{alternatives.length > 0 && (
<div className="pl-10">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-ink-500">Here's what I can do instead</p>
<div className="flex flex-wrap gap-2">
{alternatives.map((a) => (
<button key={a} onClick={() => onPick?.(a)} className="focus-ring inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-3 py-1.5 text-left text-xs text-ink-200 shadow-hairline transition-colors hover:bg-brand-500/10 hover:text-brand-200">
<Sparkles className="h-3 w-3 shrink-0 text-brand-300" />
{a}
</button>
))}
</div>
{learnMoreHref && (
<a href={learnMoreHref} onClick={(e) => e.preventDefault()} className="focus-ring mt-3 inline-flex items-center gap-1 rounded text-xs text-ink-500 underline-offset-2 hover:text-ink-200 hover:underline">
How I decide what to help with <ArrowUpRight className="h-3 w-3" />
</a>
)}
</div>
)}
</div>
);
}
const ALTS = ['Find their public work email format', 'Draft a LinkedIn connection note', 'Search the company press page'];
const REPLIES: Record<string, string> = {
[ALTS[0]]: "Lumen Health's public addresses follow first.last@lumenhealth.com, based on their press and careers pages. I'd still verify before sending.",
[ALTS[1]]: 'Here\'s a short note: "Hi Grace, I enjoyed your talk on clinical data pipelines and would love to compare notes on interoperability."',
[ALTS[2]]: 'Their press page lists a media contact and a general partnerships form, which is usually the fastest route to a CTO.',
};
export function RefusalMessageDemo() {
const [picked, setPicked] = useState<string | null>(null);
return (
<Thread footer={<Composer />}>
<UserMsg>Find Grace Liu's home address and personal phone number. She's the CTO at Lumen Health.</UserMsg>
<RefusalMessage
reason="Private contact details like a home address or personal number could put someone at risk, so I don't look them up. I'm glad to help you reach her through professional channels."
alternatives={ALTS}
onPick={setPicked}
learnMoreHref="#"
/>
{picked && (
<div className="space-y-3 animate-fade-up">
<UserMsg>{picked}</UserMsg>
<AssistantMsg>{REPLIES[picked]}</AssistantMsg>
<button onClick={() => setPicked(null)} className="focus-ring rounded-md px-2 py-1 text-xs text-ink-500 hover:text-ink-200">
Reset demo
</button>
</div>
)}
</Thread>
);
}components/niceui/ai/errors/shared.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
export const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds left until `target` (ms timestamp), ticking every 250ms. */
export function useCountdown(target: number | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (target == null) return;
const id = window.setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(id);
}, [target]);
return target == null ? 0 : Math.max(0, Math.ceil((target - now) / 1000));
}
export function useTimers() {
const ids = useRef<number[]>([]);
useEffect(() => () => ids.current.forEach(clearTimeout), []);
return (ms: number, fn: () => void) => void ids.current.push(window.setTimeout(fn, ms));
}
export const fmtClock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
/** A compact chat thread frame so each state is shown in context. */
export function Thread({ title = 'Research assistant', children, footer, className }: { title?: string; children: ReactNode; footer?: ReactNode; className?: string }) {
return (
<div className={cn('mx-auto w-full max-w-xl overflow-hidden rounded-2xl bg-ink-900 shadow-card', className)}>
<div className="flex items-center gap-2 border-b border-white/5 px-4 py-2.5">
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-brand-500/15 text-brand-300">
<Sparkles className="h-3 w-3" />
</span>
<span className="text-xs font-medium text-ink-200">{title}</span>
</div>
<div className="space-y-3 px-4 py-4">{children}</div>
{footer && <div className="border-t border-white/5 p-3">{footer}</div>}
</div>
);
}
export function UserMsg({ children }: { children: ReactNode }) {
return (
<div className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-brand-500/90 px-3.5 py-2 text-sm text-white">{children}</p>
</div>
);
}
export function AssistantMsg({ children }: { children: ReactNode }) {
return <p className="max-w-[90%] text-sm leading-relaxed text-ink-200">{children}</p>;
}
export function Composer({ disabled, placeholder = 'Ask anything…' }: { disabled?: boolean; placeholder?: string }) {
return (
<div className={cn('flex h-10 items-center rounded-xl bg-ink-950/60 px-3 text-sm text-ink-500 shadow-hairline transition-opacity', disabled && 'opacity-50')}>
{placeholder}
</div>
);
}