AI interfaces
AI action bar
A toolbar that morphs into a one-line prompt for the agent, and back.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
ai-action-bar-demo.tsx
import { Bold, Heading, Image, Link } from 'lucide-react'
import { AiActionBar } from '@/components/ai-action-bar'
export default function AiActionBarDemo() {
return (
<div className="flex justify-center">
<AiActionBar
onAsk={(prompt) => console.log('send to the agent:', prompt)}
placeholder="Make the product text shorter…"
tools={[
{ id: 'heading', label: 'Heading', icon: Heading },
{ id: 'bold', label: 'Bold', icon: Bold },
{ id: 'link', label: 'Link', icon: Link },
{ id: 'image', label: 'Image', icon: Image },
]}
/>
</div>
)
}Source
ai-action-bar.tsx
// Based on Watermelon UI's “Contextual AI bar” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with named tools and a working submit.
import { ArrowUp, Sparkles, Wrench } from 'lucide-react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState, type ComponentType, type SubmitEvent } from 'react'
import { cn } from '@/lib/utils'
export type AiBarTool = {
id: string
/** Read aloud and shown as a tooltip. */
label: string
icon: ComponentType<{ className?: string }>
onSelect?: () => void
}
export type AiActionBarProps = {
tools: AiBarTool[]
/** Runs with the instruction when the person sends it. */
onAsk: (prompt: string) => void
placeholder?: string
labels?: { tools?: string; ask?: string; send?: string }
defaultMode?: 'tools' | 'ask'
className?: string
}
/** A toolbar that morphs into a one-line prompt for the AI, and back. */
export function AiActionBar({ tools, onAsk, placeholder = 'Ask AI to change it…', labels = {}, defaultMode = 'tools', className }: AiActionBarProps) {
const [mode, setMode] = useState(defaultMode)
const [text, setText] = useState('')
const group = useId()
const { tools: toolsLabel = 'Tools', ask = 'Ask AI', send = 'Send' } = labels
const spring = { type: 'spring', stiffness: 260, damping: 32 } as const
const submit = (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
const p = text.trim()
if (!p) return
onAsk(p)
setText('')
}
const modes = [
{ id: 'tools', label: toolsLabel, Icon: Wrench },
{ id: 'ask', label: ask, Icon: Sparkles },
] as const
return (
<MotionConfig reducedMotion="user" transition={spring}>
<LayoutGroup id={group}>
<motion.div layout style={{ borderRadius: 28 }} className={cn('flex w-full max-w-md items-center gap-1 border bg-card p-1 shadow-sm', className)}>
<div role="radiogroup" aria-label="Mode" className="flex shrink-0 items-center gap-0.5 rounded-full bg-background p-1 shadow-sm">
{modes.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
role="radio"
aria-checked={mode === id}
aria-label={label}
title={label}
onClick={() => setMode(id)}
className="relative grid size-10 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{mode === id && <motion.span layoutId="mode" className="absolute inset-0 rounded-full bg-muted" />}
<Icon className="relative size-[18px]" aria-hidden />
</button>
))}
</div>
<AnimatePresence mode="popLayout" initial={false}>
{mode === 'tools' ? (
<motion.div
key="tools"
initial={{ opacity: 0, x: 24, filter: 'blur(4px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 24, filter: 'blur(4px)' }}
className="flex flex-1 items-center justify-end gap-1 pr-1"
>
{tools.map(({ id, label, icon: Icon, onSelect }) => (
<button
key={id}
type="button"
onClick={onSelect}
aria-label={label}
title={label}
className="grid size-10 place-items-center rounded-full text-foreground transition-[background-color,transform] duration-150 hover:bg-muted active:scale-[0.92]"
>
<Icon className="size-[18px]" />
</button>
))}
</motion.div>
) : (
<motion.form
key="ask"
onSubmit={submit}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex min-w-0 flex-1 items-center gap-1 pl-2"
>
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={placeholder}
aria-label={ask}
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={send}
disabled={!text.trim()}
className="grid size-10 shrink-0 place-items-center rounded-full bg-foreground text-background transition-[opacity,transform] duration-150 active:scale-[0.92] disabled:opacity-30"
>
<ArrowUp className="size-[18px]" aria-hidden />
</button>
</motion.form>
)}
</AnimatePresence>
</motion.div>
</LayoutGroup>
</MotionConfig>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/ai-action-bar.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/ai-action-bar