Cart note and gift message
Labeled order note with a live character count and a gift switch that reveals a printed gift message.
componentnoteanygiftnote
$
npx shadcn@latest add @storekit/cart-notecart-note
Live and interactive. Switch the brand in the frame header to see the same source under another token file.
Installation
$
npx shadcn@latest add @storekit/cart-note$
pnpm dlx shadcn@latest add @storekit/cart-note$
yarn dlx shadcn@latest add @storekit/cart-note$
bunx --bun shadcn@latest add @storekit/cart-noteSource
components/storekit/cart/cart-note.tsx
137 lines
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Order note and gift message. A labeled textarea with a live character
count, plus a switch that reveals a separate gift message printed on the
packing slip. Counts turn to the sale color only near the limit. */
const FIELD = "w-full resize-none rounded-sk-card border border-sk-line bg-sk-card px-3 py-2.5 text-sm leading-[1.5] text-sk-ink placeholder:text-sk-muted/70 transition-colors duration-200 focus-visible:border-sk-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent/30"
function Count({ value, max, id }: { value: string; max: number; id: string }) {
const left = max - value.length
return (
<span id={id} className={cn("text-xs tabular-nums", left <= 20 ? "text-sk-sale" : "text-sk-muted")} aria-live={left <= 20 ? "polite" : "off"}>
{value.length} / {max}
</span>
)
}
export function CartNote({
noteMax = 250,
giftMax = 150,
defaultNote = "",
defaultGift = false,
defaultGiftMessage = "",
onChange,
className,
}: {
noteMax?: number
giftMax?: number
defaultNote?: string
defaultGift?: boolean
defaultGiftMessage?: string
onChange?: (v: { note: string; gift: boolean; giftMessage: string }) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("cart", "cart-note")
const [note, setNote] = React.useState(defaultNote)
const [gift, setGift] = React.useState(defaultGift)
const [giftMessage, setGiftMessage] = React.useState(defaultGiftMessage)
const noteId = React.useId()
const giftId = React.useId()
const emit = (v: Partial<{ note: string; gift: boolean; giftMessage: string }>) => onChange?.({ note, gift, giftMessage, ...v })
return (
<div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("grid w-full gap-4", className)}>
<div>
<div className="mb-1.5 flex items-baseline justify-between gap-3">
<label htmlFor={noteId} className="text-[13px] font-medium text-sk-ink">
Order note <span className="font-normal text-sk-muted">(optional)</span>
</label>
<Count value={note} max={noteMax} id={`${noteId}-count`} />
</div>
<textarea
id={noteId}
rows={3}
maxLength={noteMax}
value={note}
aria-describedby={`${noteId}-count ${noteId}-hint`}
placeholder="Delivery instructions or anything we should know"
onChange={(e) => {
setNote(e.target.value)
emit({ note: e.target.value })
}}
className={FIELD}
/>
<p id={`${noteId}-hint`} className="mt-1 text-xs text-sk-muted">Read by the packing team. Not printed.</p>
</div>
<div className="rounded-sk-card border border-sk-line">
<button
type="button"
role="switch"
aria-checked={gift}
onClick={() => {
setGift(!gift)
emit({ gift: !gift })
sk.emit("interact", gift ? "gift_message_off" : "gift_message_on")
}}
className="flex min-h-12 w-full items-center gap-3 rounded-sk-card px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sk-accent active:scale-[0.99]"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-sk-ink">This is a gift</span>
<span className="block text-[13px] text-sk-muted">Add a message. Prices are left off the packing slip.</span>
</span>
<span aria-hidden className={cn("relative flex h-6 w-10 shrink-0 items-center rounded-sk-chip p-0.5 transition-colors duration-200", gift ? "bg-sk-accent" : "bg-sk-ink/20")}>
<motion.span
initial={false}
animate={{ x: gift ? 16 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 34 }}
className={cn("h-5 w-5 rounded-sk-chip shadow-[0_1px_2px_rgb(0_0_0/0.25)]", gift ? "bg-sk-accent-ink" : "bg-sk-paper")}
/>
</span>
</button>
<AnimatePresence initial={false}>
{gift ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="border-t border-sk-line px-4 pb-4 pt-3">
<div className="mb-1.5 flex items-baseline justify-between gap-3">
<label htmlFor={giftId} className="text-[13px] font-medium text-sk-ink">
Gift message
</label>
<Count value={giftMessage} max={giftMax} id={`${giftId}-count`} />
</div>
<textarea
id={giftId}
rows={3}
maxLength={giftMax}
value={giftMessage}
aria-describedby={`${giftId}-count`}
placeholder="Printed on a card inside the box"
onChange={(e) => {
setGiftMessage(e.target.value)
emit({ giftMessage: e.target.value })
}}
className={FIELD}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
)
}
Usage
Import from "@/components/storekit/cart/cart-note" and pass your product data. The demo above exercises the full prop surface with sample values.