dqnamoexperiments / receipt-printer

Receipt Printer

A tactile checkout experience that turns payment processing into a printed receipt, complete with the order details.

Pro plan

Annual subscription

Total

£230.40

Processing your order
"use client";import {  CheckCircleIcon,  CircleNotchIcon,} from "@phosphor-icons/react/dist/ssr";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import {  type ComponentPropsWithoutRef,  createContext,  type ReactNode,  useContext,} from "react";import { cn } from "@/helpers/classname-helper";export type ReceiptPrinterStage = "processing" | "printing" | "complete";export type ReceiptFeedMotion = "smooth" | "stepped";export type ReceiptPrinterRootProps = Omit<  ComponentPropsWithoutRef<"section">,  "children"> & {  /** Disables all stage transitions when false. */  animate?: boolean;  children: ReactNode;  /** Controls whether the paper feeds continuously or one line at a time. */  feedMotion?: ReceiptFeedMotion;  /** Current state of the printer. */  stage: ReceiptPrinterStage;};export type ReceiptPrinterMachineProps = ComponentPropsWithoutRef<"div">;export type ReceiptPrinterHeaderProps = ComponentPropsWithoutRef<"div">;export type ReceiptPrinterScreenProps = ComponentPropsWithoutRef<"div">;export type ReceiptPrinterOutputProps = ComponentPropsWithoutRef<"div">;export type ReceiptPrinterPaperProps = ComponentPropsWithoutRef<"article">;export type ReceiptPrinterStatusProps = Omit<  ComponentPropsWithoutRef<"div">,  "children"> & {  /** Custom status content. Defaults to a label derived from the current stage. */  children?: ReactNode;};type ReceiptPrinterContextValue = {  animate: boolean;  feedMotion: ReceiptFeedMotion;  shouldMove: boolean;  stage: ReceiptPrinterStage;};const ReceiptPrinterContext = createContext<ReceiptPrinterContextValue | null>(  null,);const easeOut = [0.23, 1, 0.32, 1] as const;const easeInOut = [0.77, 0, 0.175, 1] as const;const receiptToothCount = 40;const receiptToothDepth = 4;const receiptToothPoints = Array.from(  { length: receiptToothCount * 2 },  (_, index) => {    const x = 100 - ((index + 1) * 100) / (receiptToothCount * 2);    const y = index % 2 === 0 ? "100%" : `calc(100% - ${receiptToothDepth}px)`;    return `${x}% ${y}`;  },).join(", ");const receiptClipPath = `polygon(0 0, 100% 0, 100% calc(100% - ${receiptToothDepth}px), ${receiptToothPoints})`;const printingTransformKeyframes = [  "translateY(calc(-100% + 2px))",  "translateY(-91%)",  "translateY(-91%)",  "translateY(-81%)",  "translateY(-81%)",  "translateY(-70%)",  "translateY(-70%)",  "translateY(-58%)",  "translateY(-58%)",  "translateY(-45%)",  "translateY(-45%)",  "translateY(-32%)",  "translateY(-32%)",  "translateY(-20%)",  "translateY(-20%)",  "translateY(-10%)",  "translateY(-10%)",  "translateY(-3%)",  "translateY(-3%)",  "translateY(0%)",];const printingKeyframeTimes = [  0, 0.075, 0.105, 0.18, 0.21, 0.285, 0.315, 0.39, 0.42, 0.495, 0.525, 0.6,  0.63, 0.705, 0.735, 0.81, 0.84, 0.915, 0.945, 1,];const statusLabels: Record<ReceiptPrinterStage, ReactNode> = {  processing: "Processing your order",  printing: "Printing your receipt",  complete: "Order complete",};const machineClassName =  "relative isolate w-full overflow-hidden rounded-[var(--printer-radius)] border border-grayscale-12 bg-[color-mix(in_oklab,var(--color-grayscale-11)_30%,var(--color-grayscale-12))] p-[var(--printer-inset)] pb-8 shadow-[0_20px_36px_-20px_color-mix(in_oklab,var(--color-grayscale-12)_55%,transparent),0_6px_14px_-8px_color-mix(in_oklab,var(--color-grayscale-12)_24%,transparent),inset_0_1px_0_color-mix(in_oklab,var(--color-grayscale-1)_14%,transparent),inset_0_-1px_0_color-mix(in_oklab,var(--color-grayscale-12)_55%,transparent)] [--printer-inner-radius:calc(var(--printer-radius)_-_var(--printer-inset))] [--printer-inset:0.75rem] [--printer-radius:1.5rem] before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-[inherit] before:bg-[url('/textures/plastic-noise.svg')] before:bg-[length:180px_180px] before:bg-repeat before:opacity-30 before:mix-blend-multiply before:content-[''] dark:border-grayscale-3 dark:bg-grayscale-4 dark:shadow-[0_20px_36px_-20px_color-mix(in_oklab,var(--color-grayscale-3)_55%,transparent),0_6px_14px_-8px_color-mix(in_oklab,var(--color-grayscale-3)_24%,transparent),inset_0_1px_0_color-mix(in_oklab,var(--color-grayscale-12)_14%,transparent),inset_0_-1px_0_color-mix(in_oklab,var(--color-grayscale-3)_55%,transparent)]";function useReceiptPrinter(component: string) {  const context = useContext(ReceiptPrinterContext);  if (!context) {    throw new Error(`${component} must be used inside ReceiptPrinter.Root.`);  }  return context;}function ReceiptPrinterRoot({  "aria-label": ariaLabel = "Receipt printer",  animate = true,  children,  className,  feedMotion = "stepped",  stage,  ...props}: ReceiptPrinterRootProps) {  const shouldReduceMotion = useReducedMotion();  const context = {    animate,    feedMotion,    shouldMove: animate && !shouldReduceMotion,    stage,  };  return (    <ReceiptPrinterContext.Provider value={context}>      <section        aria-label={ariaLabel}        className={cn(          "relative isolate flex w-full max-w-sm flex-col items-center",          className,        )}        data-stage={stage}        {...props}      >        {children}      </section>    </ReceiptPrinterContext.Provider>  );}function ReceiptPrinterMachine({  children,  className,  ...props}: ReceiptPrinterMachineProps) {  return (    <div className={cn(machineClassName, className)} {...props}>      {children}      <div        aria-hidden="true"        className="absolute inset-x-6 bottom-[var(--printer-inset)] z-40 h-2 rounded-[0.25rem] border border-grayscale-12 bg-grayscale-12 shadow-inner shadow-grayscale-12 dark:border-grayscale-1 dark:bg-grayscale-1 dark:shadow-grayscale-1"      />    </div>  );}function ReceiptPrinterHeader({  children,  className,  ...props}: ReceiptPrinterHeaderProps) {  return (    <div      className={cn(        "relative z-10 flex h-11 items-start justify-between",        className,      )}      {...props}    >      {children}    </div>  );}function ReceiptPrinterScreen({  children,  className,  ...props}: ReceiptPrinterScreenProps) {  return (    <div      className={cn(        "relative z-10 isolate overflow-hidden rounded-[var(--printer-inner-radius)] border border-grayscale-12 bg-grayscale-12 p-4 text-grayscale-1 shadow-inner shadow-grayscale-12/80 after:pointer-events-none after:absolute after:inset-0 after:z-20 after:rounded-[inherit] after:shadow-[inset_0_0_24px_4px_color-mix(in_oklab,var(--color-grayscale-12)_52%,transparent)] after:content-[''] dark:border-grayscale-1 dark:bg-grayscale-2 dark:text-grayscale-12 dark:shadow-grayscale-1/80 dark:after:shadow-[inset_0_0_24px_4px_color-mix(in_oklab,var(--color-grayscale-1)_52%,transparent)]",        className,      )}      {...props}    >      <div className="relative z-10">{children}</div>    </div>  );}function StatusIndicator({  animate,  move,  stage,}: {  animate: boolean;  move: boolean;  stage: ReceiptPrinterStage;}) {  const isComplete = stage === "complete";  return (    <span      aria-hidden="true"      className="relative grid size-5 shrink-0 place-items-center"    >      <AnimatePresence initial={false} mode="sync">        {isComplete ? (          <motion.span            animate={{ opacity: 1, transform: "scale(1)" }}            className="col-start-1 row-start-1 grid place-items-center text-green-9"            exit={{              opacity: animate ? 0 : 1,              transform: move ? "scale(0.96)" : "scale(1)",            }}            initial={{              opacity: animate ? 0 : 1,              transform: move ? "scale(0.94)" : "scale(1)",            }}            key="complete"            transition={{ duration: animate ? 0.16 : 0, ease: easeOut }}          >            <CheckCircleIcon size={18} weight="fill" />          </motion.span>        ) : (          <motion.span            animate={{ opacity: 1, transform: "scale(1)" }}            className="col-start-1 row-start-1 grid place-items-center text-grayscale-8 dark:text-grayscale-11"            exit={{              opacity: animate ? 0 : 1,              transform: move ? "scale(0.96)" : "scale(1)",            }}            initial={{              opacity: animate ? 0 : 1,              transform: move ? "scale(0.94)" : "scale(1)",            }}            key="working"            transition={{ duration: animate ? 0.16 : 0, ease: easeOut }}          >            <CircleNotchIcon              className={cn(                animate && "animate-spin motion-reduce:animate-none",              )}              size={18}              weight="bold"            />          </motion.span>        )}      </AnimatePresence>    </span>  );}function ReceiptPrinterStatus({  children,  className,  ...props}: ReceiptPrinterStatusProps) {  const { animate, shouldMove, stage } = useReceiptPrinter(    "ReceiptPrinter.Status",  );  return (    <div      className={cn("flex min-w-0 items-center gap-2", className)}      {...props}    >      <StatusIndicator animate={animate} move={shouldMove} stage={stage} />      <div        aria-live="polite"        className="grid min-w-0 flex-1 items-center"        role="status"      >        <AnimatePresence initial={false} mode="sync">          <motion.div            animate={{ opacity: 1, transform: "translateY(0px)" }}            className="col-start-1 row-start-1 truncate font-medium text-grayscale-8 text-xs leading-none dark:text-grayscale-11"            exit={{              opacity: animate ? 0 : 1,              transform: shouldMove ? "translateY(-4px)" : "translateY(0px)",            }}            initial={{              opacity: animate ? 0 : 1,              transform: shouldMove ? "translateY(4px)" : "translateY(0px)",            }}            key={stage}            transition={{ duration: animate ? 0.18 : 0, ease: easeOut }}          >            {children ?? statusLabels[stage]}          </motion.div>        </AnimatePresence>      </div>    </div>  );}function ReceiptPrinterPaper({  children,  className,  style,  ...props}: ReceiptPrinterPaperProps) {  return (    <article      className={cn(        "relative z-10 min-h-80 bg-grayscale-1 bg-[url('/textures/receipt-paper.svg')] bg-cover px-6 pt-7 pb-8 font-mono text-grayscale-12 bg-blend-soft-light dark:bg-grayscale-12 dark:text-grayscale-1",        className,      )}      style={{ clipPath: receiptClipPath, ...style }}      {...props}    >      {children}    </article>  );}function ReceiptPrinterOutput({  children,  className,  ...props}: ReceiptPrinterOutputProps) {  const { animate, feedMotion, shouldMove, stage } = useReceiptPrinter(    "ReceiptPrinter.Output",  );  const isReceiptVisible = stage !== "processing";  const shouldUseSteppedFeed =    feedMotion === "stepped" && stage === "printing" && shouldMove;  return (    <div      className={cn(        "relative z-50 -mt-4 h-[32rem] w-[calc(80%+3rem)] max-w-full overflow-hidden px-6",        className,      )}      {...props}    >      {isReceiptVisible ? (        <div          aria-hidden="true"          className="pointer-events-none absolute inset-x-6 -top-1 z-20 h-2 bg-grayscale-12/75 blur-[6px] dark:bg-grayscale-1/75"        />      ) : null}      <motion.div        animate={{          opacity: isReceiptVisible ? 1 : 0,          transform:            stage === "printing" && shouldMove              ? shouldUseSteppedFeed                ? printingTransformKeyframes                : "translateY(0%)"              : isReceiptVisible || !shouldMove                ? "translateY(0%)"                : "translateY(calc(-100% + 2px))",        }}        aria-hidden={stage !== "complete"}        className="relative isolate before:pointer-events-none before:absolute before:inset-x-3 before:top-3 before:bottom-4 before:z-0 before:rounded-sm before:shadow-[0_8px_24px_color-mix(in_oklab,var(--color-grayscale-12)_24%,transparent)] before:content-[''] after:pointer-events-none after:absolute after:right-[8%] after:bottom-0 after:left-[8%] after:z-0 after:h-3 after:translate-y-1.5 after:rounded-full after:bg-grayscale-12/10 after:blur-lg after:content-[''] dark:before:shadow-[0_8px_24px_color-mix(in_oklab,var(--color-grayscale-1)_20%,transparent)] dark:after:bg-grayscale-1/10"        initial={false}        transition={{          opacity: { duration: animate ? 0.16 : 0, ease: easeOut },          transform: {            duration: shouldMove ? 1.75 : 0,            ease: shouldUseSteppedFeed ? "linear" : easeInOut,            times: shouldUseSteppedFeed ? printingKeyframeTimes : undefined,          },        }}      >        {children}      </motion.div>    </div>  );}export const ReceiptPrinter = {  Header: ReceiptPrinterHeader,  Machine: ReceiptPrinterMachine,  Output: ReceiptPrinterOutput,  Paper: ReceiptPrinterPaper,  Root: ReceiptPrinterRoot,  Screen: ReceiptPrinterScreen,  Status: ReceiptPrinterStatus,};

Want to work with me?

I run a lil design engineering studio in London where we do fractional design engineering for startups.

THEINTERFACECOMPANYOF LONDON