Anatomy of Prompt Suggestions
Three container layouts sharing one item, a spring stagger with a single per-item variable, and a hover lift that never touches layout.
↓Scroll to step through it
One item, three wrappers
PromptSuggestions doesn't have three separate layouts. It has one
motion.button per suggestion and a lookup table that decides how the
wrapper arranges them:
The button itself branches once, on isChip, to pick between a pill
className and a card className. There is no separate component per variant:
className={
isChip
? "group inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 …"
: "group flex items-start gap-2.5 rounded-xl border border-border bg-card p-3 …"
}That's why hint only renders outside chips ({suggestion.hint && !isChip ? … : null}): a chip is one line by design, a card has room for a
second. Swapping variant never remounts a different tree; it only changes
which layout classes and which of these two branches apply.
A spring stagger with one moving part
Every suggestion animates in with the same spring. The only thing that
changes per item is delay:
320/32/0.9 is a slightly underdamped spring, the same one used in
GodUI's other enter animations (badges, command palette rows), so each
card overshoots its resting position by a hair before settling instead of
easing to a flat stop. index * 0.05 is the entire stagger. There is no
staggerChildren and no parent motion.div orchestrating children; each
button computes its own 50ms offset from its position in the array.
Reduced motion collapses delay to 0 and swaps initial to false in
the same expression. The spring is gone and the layout stays.
Hover moves the box, never the layout
Hover is a second, independent motion system in plain Tailwind (not Framer),
and it only touches transform and opacity:
-translate-y-px and -translate-y-0.5 lift the item on the compositor,
with no top, no margin, nothing the browser has to re-lay-out. The arrow
follows the same rule from the opposite direction: it's rendered at
opacity-0 -translate-x-1 on every card from the start, so revealing it on
hover is a transform + opacity flip, not a mount. Its box never changes
size, which is why neighboring text never reflows when the arrow shows up.
Cards also swap shadow-2xs for shadow-md on hover. That is the one
paint property, and it is safe here because it's gated behind a
single hovered element rather than something looping on scroll.
Setting loading doesn't hide the component behind a spinner. It renders
the same container with skeleton placeholders sized per variant:
<div className={CONTAINER_BY_VARIANT[variant]}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<div
key={i}
className={`rounded-xl border border-border bg-muted/40 motion-safe:animate-pulse ${
variant === "chips" ? "h-9 w-32" : "h-16"
}`}
/>
))}
</div>Because it's the same CONTAINER_BY_VARIANT[variant] wrapper, the
skeleton grid occupies the same footprint the real suggestions will once
they arrive, so swapping loading off never causes a layout jump.
motion-safe:animate-pulse keeps the shimmer itself opt-out under reduced
motion, same as the entrance spring above.
The result
One array of suggestions, one variant string picking a wrapper class, one
spring staggered by index, and a hover system that only ever moves
transform/opacity.
grid
chips
list
- Grid
- grid grid-cols-1 sm:grid-cols-2 · card + hint + arrow
- Chips
- flex flex-wrap · rounded-full pill, no hint
- List
- flex flex-col · full-width row + arrow
const CONTAINER_BY_VARIANT: Record<PromptSuggestionsVariant, string> = {grid: "grid grid-cols-1 gap-2 sm:grid-cols-2",chips: "flex flex-wrap gap-2",list: "flex flex-col gap-1.5",};<div className={CONTAINER_BY_VARIANT[variant]}>{suggestions.map((suggestion, index) => ( <motion.button key={suggestion.id ?? suggestion.label} /* … */ />))}</div>Anatomy of Prompt Suggestions
Three container layouts sharing one item, a spring stagger with a single per-item variable, and a hover lift that never touches layout.
grid
chips
list
- Grid
- grid grid-cols-1 sm:grid-cols-2 · card + hint + arrow
- Chips
- flex flex-wrap · rounded-full pill, no hint
- List
- flex flex-col · full-width row + arrow
const CONTAINER_BY_VARIANT: Record<PromptSuggestionsVariant, string> = {grid: "grid grid-cols-1 gap-2 sm:grid-cols-2",chips: "flex flex-wrap gap-2",list: "flex flex-col gap-1.5",};<div className={CONTAINER_BY_VARIANT[variant]}>{suggestions.map((suggestion, index) => ( <motion.button key={suggestion.id ?? suggestion.label} /* … */ />))}</div>One item, three wrappers
PromptSuggestions doesn't have three separate layouts. It has one
motion.button per suggestion and a lookup table that decides how the
wrapper arranges them:
The button itself branches once, on isChip, to pick between a pill
className and a card className. There is no separate component per variant:
className={
isChip
? "group inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 …"
: "group flex items-start gap-2.5 rounded-xl border border-border bg-card p-3 …"
}That's why hint only renders outside chips ({suggestion.hint && !isChip ? … : null}): a chip is one line by design, a card has room for a
second. Swapping variant never remounts a different tree; it only changes
which layout classes and which of these two branches apply.
- #0
- #1
- #2
- #3
- 0ms
- 50ms
- 100ms
- 150ms
<motion.buttoninitial={reduce ? false : { opacity: 0, y: 8 }}animate={{ opacity: 1, y: 0 }}transition={{ delay: reduce ? 0 : index * 0.05, type: "spring", stiffness: 320, damping: 32, mass: 0.9,}}/>A spring stagger with one moving part
Every suggestion animates in with the same spring. The only thing that
changes per item is delay:
320/32/0.9 is a slightly underdamped spring, the same one used in
GodUI's other enter animations (badges, command palette rows), so each
card overshoots its resting position by a hair before settling instead of
easing to a flat stop. index * 0.05 is the entire stagger. There is no
staggerChildren and no parent motion.div orchestrating children; each
button computes its own 50ms offset from its position in the array.
Reduced motion collapses delay to 0 and swaps initial to false in
the same expression. The spring is gone and the layout stays.
chip
card
- Chip
- translateY −1px only, no shadow, no arrow
- Card
- translateY −2px + shadow layer opacity .22→.5, arrow slides in
// chip"hover:-translate-y-px hover:border-ring hover:bg-accent"// card"hover:-translate-y-0.5 hover:border-ring hover:shadow-md"// arrow (card only)"ml-auto size-4 -translate-x-1 opacity-0 transition-[transform,opacity] group-hover:translate-x-0 group-hover:opacity-100"Hover moves the box, never the layout
Hover is a second, independent motion system in plain Tailwind (not Framer),
and it only touches transform and opacity:
-translate-y-px and -translate-y-0.5 lift the item on the compositor,
with no top, no margin, nothing the browser has to re-lay-out. The arrow
follows the same rule from the opposite direction: it's rendered at
opacity-0 -translate-x-1 on every card from the start, so revealing it on
hover is a transform + opacity flip, not a mount. Its box never changes
size, which is why neighboring text never reflows when the arrow shows up.
Cards also swap shadow-2xs for shadow-md on hover. That is the one
paint property, and it is safe here because it's gated behind a
single hovered element rather than something looping on scroll.
Setting loading doesn't hide the component behind a spinner. It renders
the same container with skeleton placeholders sized per variant:
<div className={CONTAINER_BY_VARIANT[variant]}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<div
key={i}
className={`rounded-xl border border-border bg-muted/40 motion-safe:animate-pulse ${
variant === "chips" ? "h-9 w-32" : "h-16"
}`}
/>
))}
</div>Because it's the same CONTAINER_BY_VARIANT[variant] wrapper, the
skeleton grid occupies the same footprint the real suggestions will once
they arrive, so swapping loading off never causes a layout jump.
motion-safe:animate-pulse keeps the shimmer itself opt-out under reduced
motion, same as the entrance spring above.
Click or arrow-key to a suggestion and select it
The result
One array of suggestions, one variant string picking a wrapper class, one
spring staggered by index, and a hover system that only ever moves
transform/opacity.
Reduced motion and the keyboard
useReducedMotion() gates the stagger with initial={reduce ? false : {...}}
and delay: reduce ? 0 : index * 0.05, so a reduced-motion user gets the
finished layout on the first frame instead of a slowed-down or stripped
version of the same animation. Nothing about focus or selection depends on
it, because that logic lives in a separate path:
const itemsRef = React.useRef<(HTMLButtonElement | null)[]>([]);
const focusItem = (index: number) => {
const list = itemsRef.current.filter(Boolean) as HTMLButtonElement[];
if (list.length === 0) return;
const next = (index + list.length) % list.length;
list[next]?.focus();
};
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
e.preventDefault();
focusItem(index + 1);
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
e.preventDefault();
focusItem(index - 1);
}
}}focusItem wraps with a modulo, so arrow keys cycle the row instead of
running off either end. It works identically across grid, chips,
and list, because it walks itemsRef (population order), not the DOM's
2D layout. ArrowDown/ArrowRight both move forward and
ArrowUp/ArrowLeft both move back, so the same two key pairs make sense
whether the items are stacked in a column or wrapped in a row.
Motion Score
translatePosition / lift via translateopacityFade / cross-fade