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-note
cart-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-note

Source

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.