Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Dqnamo Kitchen; component and provider source published by JP / dqnamo. Preview: platform recorded · rights authorized.
A SaaS checkout state that prints a physical receipt when payment clears.
dSource LanddqnamoWhy it stands out
A checkout confirmation is represented as a miniature physical receipt printer. When payment clears, the paper advances from the machine and reveals an itemized SaaS receipt, turning a routine success state into a memorable product moment.
"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,
};Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from dqnamo