"use client";
import * as React from "react";
import { ArrowUpIcon, BoltIcon, PaperClipIcon, SparklesIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { useStreamText } from "@/lib/use-stream-text";
const rotating = [
"Plan a product launch for next quarter…",
"Summarize this PDF in three bullets…",
"Write a SQL query for weekly retention…",
"Draft a reply to this customer…",
];
const chips = ["Explain a codebase", "Write a launch post", "Analyze a spreadsheet", "Plan my week"];
const replies: Record<string, string> = {
default: `**Here's a quick plan.**\n\n1. Define the outcome you want in one sentence.\n2. Break it into three milestones with owners.\n3. Ship the smallest useful slice this week.\n\nWant me to turn this into a checklist?`,
};
/** Typewriter that cycles through placeholder prompts. */
function useRotatingPlaceholder(items: string[], enabled: boolean) {
const [text, setText] = React.useState("");
React.useEffect(() => {
if (!enabled) return;
let item = 0;
let pos = 0;
let deleting = false;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const target = items[item % items.length];
if (!deleting) {
pos++;
setText(target.slice(0, pos));
if (pos === target.length) {
deleting = true;
timer = setTimeout(tick, 1600);
return;
}
timer = setTimeout(tick, 32);
} else {
pos -= 3;
setText(target.slice(0, Math.max(0, pos)));
if (pos <= 0) {
deleting = false;
item++;
timer = setTimeout(tick, 250);
return;
}
timer = setTimeout(tick, 18);
}
};
timer = setTimeout(tick, 400);
return () => clearTimeout(timer);
}, [items, enabled]);
return text;
}
export type AIHeroProps = {
className?: string;
headline?: string;
subhead?: string;
};
export default function AIHero({ className, headline = "Your product, with a brain", subhead = "Drop-in chat, agents, and answers that match your design system. Built on components you own." }: AIHeroProps) {
const [draft, setDraft] = React.useState("");
const [asked, setAsked] = React.useState<string | null>(null);
const { text, isStreaming, start } = useStreamText(16);
const placeholder = useRotatingPlaceholder(rotating, draft === "");
const submit = (value: string) => {
const v = value.trim();
if (!v) return;
setAsked(v);
setDraft("");
void start(replies.default);
};
return (
<section data-slot="ai-hero" className={cn("relative flex min-h-full w-full flex-col items-center overflow-hidden rounded-3xl border bg-background px-6 py-16 shadow-xs sm:py-24", className)}>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,var(--brand-soft),transparent_60%)] opacity-70 dark:opacity-40" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand/50 to-transparent" />
<div className="relative flex w-full max-w-3xl flex-col items-center gap-6 text-center">
<span className="inline-flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground shadow-xs backdrop-blur">
<span className="flex size-4 items-center justify-center rounded-full bg-brand text-primary-foreground"><SparklesIcon size={10} className="flex" /></span>
Now with agents and voice
</span>
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-6xl">{headline}</h1>
<p className="max-w-xl text-balance text-base text-muted-foreground sm:text-lg">{subhead}</p>
<div className="mt-2 w-full max-w-2xl">
<PromptInput onSubmit={submit} className="shadow-modal">
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={placeholder || " "} className="min-h-14 text-base" />
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach"><Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Attach"><PaperClipIcon size={16} className="flex" /></Button></PromptInputAction>
<span className="inline-flex h-8 items-center gap-1.5 rounded-full border px-2.5 text-xs text-muted-foreground"><BoltIcon size={12} className="flex" /> Fast mode</span>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Send" disabled={!draft.trim() || isStreaming} onClick={() => submit(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
<Suggestions onSelect={submit}>
<SuggestionList className="justify-center">{chips.map((c) => <Suggestion key={c} variant="outline">{c}</Suggestion>)}</SuggestionList>
</Suggestions>
{asked ? (
<div className="mt-4 w-full max-w-2xl rounded-2xl border bg-card/80 p-5 text-left shadow-xs backdrop-blur animate-in fade-in-0 slide-in-from-bottom-2">
<p className="mb-3 text-xs text-muted-foreground">You asked: <span className="text-foreground">{asked}</span></p>
{text ? <MessageMarkdown isAnimating={isStreaming}>{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Thinking…</TextShimmer>}
</div>
) : null}
<div className="mt-8 flex flex-col items-center gap-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Trusted by teams at</span>
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-sm font-semibold tracking-tight text-muted-foreground/70">
{["Northwind", "Acme Cloud", "Lumen Labs", "Orbital", "Fjord"].map((n) => <span key={n}>{n}</span>)}
</div>
</div>
</div>
</section>
);
}"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}What’s inside
- Prompt Input with a typewriter placeholder that cycles through example prompts.
- Suggestions as outline chips under the input.
- Message markdown renders the streamed sample reply in a card.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/ai-hero.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/ai-hero.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/ai-hero.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/ai-hero.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import { ArrowUpIcon, BoltIcon, PaperClipIcon, SparklesIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { useStreamText } from "@/lib/use-stream-text";
const rotating = [
"Plan a product launch for next quarter…",
"Summarize this PDF in three bullets…",
"Write a SQL query for weekly retention…",
"Draft a reply to this customer…",
];
const chips = ["Explain a codebase", "Write a launch post", "Analyze a spreadsheet", "Plan my week"];
const replies: Record<string, string> = {
default: `**Here's a quick plan.**\n\n1. Define the outcome you want in one sentence.\n2. Break it into three milestones with owners.\n3. Ship the smallest useful slice this week.\n\nWant me to turn this into a checklist?`,
};
/** Typewriter that cycles through placeholder prompts. */
function useRotatingPlaceholder(items: string[], enabled: boolean) {
const [text, setText] = React.useState("");
React.useEffect(() => {
if (!enabled) return;
let item = 0;
let pos = 0;
let deleting = false;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const target = items[item % items.length];
if (!deleting) {
pos++;
setText(target.slice(0, pos));
if (pos === target.length) {
deleting = true;
timer = setTimeout(tick, 1600);
return;
}
timer = setTimeout(tick, 32);
} else {
pos -= 3;
setText(target.slice(0, Math.max(0, pos)));
if (pos <= 0) {
deleting = false;
item++;
timer = setTimeout(tick, 250);
return;
}
timer = setTimeout(tick, 18);
}
};
timer = setTimeout(tick, 400);
return () => clearTimeout(timer);
}, [items, enabled]);
return text;
}
export type AIHeroProps = {
className?: string;
headline?: string;
subhead?: string;
};
export default function AIHero({ className, headline = "Your product, with a brain", subhead = "Drop-in chat, agents, and answers that match your design system. Built on components you own." }: AIHeroProps) {
const [draft, setDraft] = React.useState("");
const [asked, setAsked] = React.useState<string | null>(null);
const { text, isStreaming, start } = useStreamText(16);
const placeholder = useRotatingPlaceholder(rotating, draft === "");
const submit = (value: string) => {
const v = value.trim();
if (!v) return;
setAsked(v);
setDraft("");
void start(replies.default);
};
return (
<section data-slot="ai-hero" className={cn("relative flex min-h-full w-full flex-col items-center overflow-hidden rounded-3xl border bg-background px-6 py-16 shadow-xs sm:py-24", className)}>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,var(--brand-soft),transparent_60%)] opacity-70 dark:opacity-40" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand/50 to-transparent" />
<div className="relative flex w-full max-w-3xl flex-col items-center gap-6 text-center">
<span className="inline-flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground shadow-xs backdrop-blur">
<span className="flex size-4 items-center justify-center rounded-full bg-brand text-primary-foreground"><SparklesIcon size={10} className="flex" /></span>
Now with agents and voice
</span>
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-6xl">{headline}</h1>
<p className="max-w-xl text-balance text-base text-muted-foreground sm:text-lg">{subhead}</p>
<div className="mt-2 w-full max-w-2xl">
<PromptInput onSubmit={submit} className="shadow-modal">
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={placeholder || " "} className="min-h-14 text-base" />
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip="Attach"><Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground" aria-label="Attach"><PaperClipIcon size={16} className="flex" /></Button></PromptInputAction>
<span className="inline-flex h-8 items-center gap-1.5 rounded-full border px-2.5 text-xs text-muted-foreground"><BoltIcon size={12} className="flex" /> Fast mode</span>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction asChild tooltip={{ content: "Send", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Send" disabled={!draft.trim() || isStreaming} onClick={() => submit(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
<Suggestions onSelect={submit}>
<SuggestionList className="justify-center">{chips.map((c) => <Suggestion key={c} variant="outline">{c}</Suggestion>)}</SuggestionList>
</Suggestions>
{asked ? (
<div className="mt-4 w-full max-w-2xl rounded-2xl border bg-card/80 p-5 text-left shadow-xs backdrop-blur animate-in fade-in-0 slide-in-from-bottom-2">
<p className="mb-3 text-xs text-muted-foreground">You asked: <span className="text-foreground">{asked}</span></p>
{text ? <MessageMarkdown isAnimating={isStreaming}>{text}</MessageMarkdown> : <TextShimmer className="text-sm text-muted-foreground" invertLight>Thinking…</TextShimmer>}
</div>
) : null}
<div className="mt-8 flex flex-col items-center gap-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Trusted by teams at</span>
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-sm font-semibold tracking-tight text-muted-foreground/70">
{["Northwind", "Acme Cloud", "Lumen Labs", "Orbital", "Fjord"].map((n) => <span key={n}>{n}</span>)}
</div>
</div>
</div>
</section>
);
}"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}The registry item pulls in every component it depends on.
Usage
import AIHero from "@/components/blocks/ai-hero/ai-hero";
export default function Page() {
return (
<div className="h-dvh p-4">
<AIHero />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Wire submit to open your real chat or redirect to signup with the prompt prefilled. The headline and subhead props cover the copy; edit the rotating prompts array to match your product.