Context Pill Attach
A dragged file is caught by the prompt bar and lands inside it as a context pill.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Context Pill Attach
*
* Dragging a file onto the prompt bar. Release it over the bar and it
* stops being a file: it becomes a context pill sitting inside the
* prompt, and the bar confirms the catch with a ring that fades.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `file`, `placeholder`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ContextFile = {
name: string;
/** Type and size, shown under the name. */
meta: string;
};
export type ContextPillAttachProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The file offered as context. */
file?: ContextFile;
/** Prompt bar placeholder. */
placeholder?: string;
/** px the card must travel downward to count as a drop. */
dropDistance?: number;
/** Accent for the pill and the confirmation ring. */
color?: string;
};
type VariantConfig = {
/** px the pill travels as it lands in the bar. */
pillDrop: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds the confirmation ring takes to fade. */
ringSeconds: number;
/** How far the card can be pulled past its constraints. */
dragElastic: number;
};
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. The
// pill lands under the pointer that just released it; a pill that
// bounced would read as dropped rather than caught.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.07 — the pill simply appears in place. For dense composers.
subtle: {
pillDrop: 4,
spring: { type: "spring", stiffness: 460, damping: 46 },
ringSeconds: 0.4,
dragElastic: 0.1,
},
// ζ ≈ 0.89 — lands clean. The all-purpose setting.
default: {
pillDrop: 9,
spring: { type: "spring", stiffness: 500, damping: 40 },
ringSeconds: 0.55,
dragElastic: 0.14,
},
// ζ ≈ 0.85 — one soft settle and a looser pull, for a hero composer.
playful: {
pillDrop: 14,
spring: { type: "spring", stiffness: 400, damping: 34 },
ringSeconds: 0.7,
dragElastic: 0.2,
},
};
const SAMPLE_FILE: ContextFile = {
name: "Q3-forecast.pdf",
meta: "PDF · 2.4 MB",
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. The accent stays literal:
* it marks state, it is not a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
function FileGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 2h5l3 3v9H4z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path
d="M8.8 2.2v3.1h3.1"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</svg>
);
}
export default function ContextPillAttach({
variant = "default",
file = SAMPLE_FILE,
placeholder = "Ask something about the attached file",
dropDistance = 46,
color = "#7C7CF0",
}: ContextPillAttachProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [attached, setAttached] = useState(false);
const [armed, setArmed] = useState(false);
// Bumped on every catch so the confirmation ring remounts and replays.
const [catches, setCatches] = useState(0);
const attach = () => {
setArmed(false);
setAttached(true);
setCatches((count) => count + 1);
};
return (
<div style={{ width: 320, display: "grid", gap: 14 }}>
<div style={{ minHeight: 66, display: "grid" }}>
<AnimatePresence initial={false}>
{!attached && (
<motion.div
key="file"
drag={!attached}
dragSnapToOrigin
dragMomentum={false}
dragElastic={cfg.dragElastic}
dragConstraints={{ top: -10, bottom: 116, left: -48, right: 48 }}
onDrag={(_event, info) =>
setArmed(info.offset.y > dropDistance * 0.6)
}
onDragEnd={(_event, info) => {
setArmed(false);
if (info.offset.y > dropDistance) attach();
}}
// Only the shadow and the border respond to the grab. The
// card is mostly a filename, and lifting text by scaling it
// is the fastest way to make a drag look cheap.
whileDrag={{
boxShadow: "0 18px 38px rgba(0,0,0,0.26)",
cursor: "grabbing",
}}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.14 } }
: {
opacity: 0,
y: 10,
transition: { duration: 0.18, ease: "easeIn" },
}
}
transition={{
y: cfg.spring,
opacity: { duration: 0.2, ease: "easeOut" },
}}
style={{
gridArea: "1 / 1",
display: "flex",
alignItems: "center",
gap: 11,
padding: "11px 13px",
borderRadius: 13,
background: tone(6),
border: `1px solid ${armed ? color : tone(13)}`,
transition: "border-color 160ms ease",
boxShadow: "0 2px 8px rgba(0,0,0,0.06)",
cursor: "grab",
touchAction: "none",
userSelect: "none",
}}
>
<span
style={{
display: "grid",
placeItems: "center",
width: 32,
height: 32,
flex: "0 0 auto",
borderRadius: 9,
background: tone(9),
opacity: 0.72,
}}
>
<FileGlyph />
</span>
<span style={{ display: "grid", gap: 2, minWidth: 0 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{file.name}
</span>
<span style={{ fontSize: 11, opacity: 0.45 }}>{file.meta}</span>
</span>
<span
style={{
marginLeft: "auto",
fontSize: 11,
opacity: 0.4,
whiteSpace: "nowrap",
}}
>
Drag down
</span>
</motion.div>
)}
</AnimatePresence>
</div>
<div
style={{
position: "relative",
display: "grid",
gap: 9,
padding: 11,
borderRadius: 16,
background: tone(5),
border: `1px solid ${armed ? color : tone(13)}`,
transition: "border-color 160ms ease",
}}
>
{/* The catch confirmation: a ring at the bar edge that fades out
once, rather than a color that has to be undone. */}
<motion.span
key={catches}
aria-hidden
initial={{ opacity: catches > 0 ? 0.85 : 0 }}
animate={{ opacity: 0 }}
transition={{ duration: cfg.ringSeconds, ease: "easeOut" }}
style={{
position: "absolute",
inset: -2,
borderRadius: 18,
border: `2px solid ${color}`,
pointerEvents: "none",
}}
/>
<AnimatePresence initial={false}>
{attached && (
<motion.div
key="pill"
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.pillDrop }
}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.14 } }}
transition={{
y: cfg.spring,
opacity: { duration: 0.18, ease: "easeOut" },
}}
style={{ display: "flex", flexWrap: "wrap", gap: 6 }}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 5px 4px 9px",
borderRadius: 999,
background: tone(9),
border: `1px solid ${tone(13)}`,
fontSize: 11.5,
fontWeight: 600,
lineHeight: 1,
}}
>
<span style={{ display: "inline-flex", color, opacity: 0.9 }}>
<FileGlyph />
</span>
{file.name}
<button
type="button"
onClick={() => setAttached(false)}
aria-label={`Remove ${file.name} from context`}
style={{
display: "grid",
placeItems: "center",
width: 17,
height: 17,
borderRadius: "50%",
border: "none",
background: "transparent",
color: "inherit",
opacity: 0.45,
cursor: "pointer",
padding: 0,
}}
>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<path
d="M1.8 1.8 8.2 8.2M8.2 1.8 1.8 8.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</span>
</motion.div>
)}
</AnimatePresence>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "2px 2px 2px 4px",
}}
>
<span style={{ flex: 1, fontSize: 13, opacity: 0.38 }}>
{placeholder}
</span>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
flex: "0 0 auto",
borderRadius: "50%",
background: tone(10),
opacity: 0.6,
}}
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path
d="M7 11.6V2.6M3.2 6.2 7 2.4l3.8 3.8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
opacity: 0.45,
}}
>
<span>{attached ? "1 file in context" : "Drop the file on the bar"}</span>
<button
type="button"
onClick={() => (attached ? setAttached(false) : attach())}
style={{
padding: 0,
border: "none",
background: "transparent",
color: "inherit",
font: "inherit",
fontSize: 11.5,
fontWeight: 600,
textDecoration: "underline",
textUnderlineOffset: 2,
cursor: "pointer",
}}
>
{attached ? "Remove it" : "Attach without dragging"}
</button>
</div>
</div>
);
}About this pattern
Attaching context is a promise that the assistant will actually read the thing, so the drop has to be unambiguous. The bar arms as the card crosses the threshold, the card leaves downward on release, and the pill lands inside the bar on a tightly damped spring while a ring at the bar edge fades once to confirm the catch. Nothing about the card scales while it is held — only its shadow answers the grab, because a filename stretched under the pointer is the cheapest looking drag there is.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Files dropped on the composer become compact chips inside the prompt before it is sent.
Related patterns
- Autocomplete RiseA completion panel lifts into place under the field, its rows landing a beat apart.
- Prompt Submit LiftThe composed prompt lifts out of the field and docks as a sent message while the thread makes room.
- Prompt Template FillPicking a starter writes its outline into the composer with the blanks lighting up.