Gemini
A full recreation of the Gemini chat with the animated sparkle, streamed replies and a model picker.
A rebuild of the Gemini web app’s chat thread, composed from the Gemini components. The reply carries a collapsed Show thinking step with a search, and source chips that open a hover card. Type a message to get a reply.
"use client";
import { useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { GeminiActions } from "../components/gemini-actions";
import { GeminiComposer } from "../components/gemini-composer";
import { GeminiMessage } from "../components/gemini-message";
import { GeminiStoryText } from "../components/gemini-story-text";
import { GeminiThinking } from "../components/gemini-thinking";
import { GeminiThoughts } from "../components/gemini-thoughts";
import {
GEMINI_STORY_REPLIES,
GEMINI_STORY_THREAD,
} from "../constants/gemini-story";
import { useGeminiPlayback } from "../hooks/use-gemini-playback";
import { useGeminiReducedMotion } from "../hooks/use-gemini-reduced-motion";
export default function GeminiDemo() {
const reducedMotion = useGeminiReducedMotion();
const playback = useGeminiPlayback(GEMINI_STORY_THREAD, reducedMotion);
const replyIndexRef = useRef(0);
const scrollRef = useRef<HTMLDivElement>(null);
const hasMountedRef = useRef(false);
// Follow new messages inside the fixed-height card. Scrolling the viewport
// directly (not scrollIntoView) keeps the host page from jumping.
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
const viewport = scrollRef.current?.querySelector(
'[data-slot="scroll-area-viewport"]'
);
viewport?.scrollTo({
behavior: reducedMotion ? "auto" : "smooth",
top: viewport.scrollHeight,
});
}, [playback.messages, playback.thinking, reducedMotion]);
const handleSend = (text: string) => {
if (playback.playing) {
return;
}
const reply =
GEMINI_STORY_REPLIES[
replyIndexRef.current % GEMINI_STORY_REPLIES.length
] ?? "Sure.";
replyIndexRef.current += 1;
playback.send(text, reply).catch(() => undefined);
};
return (
<Card
className="border-gemini-border bg-gemini-bg font-gemini text-gemini-fg h-150 w-full min-w-0 gap-0 overflow-hidden rounded-2xl border py-0 text-base ring-0"
data-slot="gemini-demo"
>
<CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 ps-5 pe-3">
<CardTitle className="text-gemini-muted text-base leading-6 font-normal">
Gemini
</CardTitle>
</CardHeader>
<ScrollArea className="min-h-0 flex-1" ref={scrollRef}>
<CardContent
aria-live="polite"
className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-8 px-5 pt-4 pb-8"
role="log"
>
{playback.messages.map((message) => (
<GeminiMessage
actions={
message.from === "assistant" ? (
<div
className={
playback.completeIds.has(message.id)
? undefined
: "invisible"
}
>
<GeminiActions />
</div>
) : undefined
}
from={message.from}
key={message.id}
thoughts={
message.thoughts ? (
<GeminiThoughts steps={message.thoughts} />
) : undefined
}
>
{message.from === "user" ? (
message.text
) : (
<GeminiStoryText
sources={message.sources}
text={message.text}
/>
)}
</GeminiMessage>
))}
{playback.thinking ? (
<GeminiThinking
label={playback.thinkingLabel}
reducedMotion={reducedMotion}
/>
) : null}
</CardContent>
</ScrollArea>
<CardContent className="mx-auto w-full max-w-3xl min-w-0 shrink-0 px-5 pb-5">
<GeminiComposer
busy={playback.playing || playback.thinking}
onSend={handleSend}
onStop={playback.stop}
/>
</CardContent>
</Card>
);
}
Installation
$ bunx --bun shadcn@latest add @notra/geminiThis adds every Gemini component to components/gemini, with the demo, its sample thread and the useGeminiPlayback hook that streams replies.
The components use Google Sans Flex with a Google Sans, Roboto and Arial fallback. Load Google Sans Flex in your app for the closest match.
Composition
There’s no single root. A thread is a list of GeminiMessage turns, with GeminiThinking as the status of a pending reply and GeminiActions under finished ones, above a GeminiComposer. The demo paints the background with bg-gemini-bg and handles scrolling itself.
useGeminiPlayback(script, reducedMotion) plays a script of { id, from, text, search? } messages. It shows a search or thinking status, streams each reply word by word and returns stop and send. With reduced motion it skips the streaming.
Theming
The block is built on the shadcn Base UI style. It uses shadcn Avatar, Badge, Button, Card, Collapsible, DropdownMenu, HoverCard, InputGroup, ScrollArea and Tooltip, plus the Notra Shimmer, and the CLI installs any you’re missing.
Its colors are --gemini-* tokens that the CLI adds to your CSS with Gemini’s light and dark values, mapped to utilities like bg-gemini-bubble and text-gemini-fg. The composer and menu shadows are --gemini-composer-shadow and --gemini-popover-shadow. Change them in your CSS to retheme it, or point them at your own tokens:
:root {
--gemini-bg: var(--background);
--gemini-fg: var(--foreground);
--gemini-bubble: var(--muted);
}
Trademarks
Gemini and Google are trademarks of Google LLC, a subsidiary of Alphabet Inc. Notra is not affiliated with or endorsed by Google. See Trademarks.