AniUI

Coupon

Two-section promotional coupon with a real tear-line, ticket icon, and orientation prop.

Coupon

20% OFF

First order discount · ANIUI20

Expires Dec 31, 2026

SAVE
Web preview — components render natively on iOS & Android
import { Coupon } from "@/components/ui/coupon";
<Coupon code="ANIUI20" discount="20% OFF" />

Installation#

npx @aniui/cli add coupon

Usage#

import { Coupon } from "@/components/ui/coupon";
<Coupon code="ANIUI20" discount="20% OFF" />

Source#

components/ui/coupon.tsx
import React, { createContext, useContext, useMemo } from "react";
import { View, Text } from "react-native";
import { Ticket } from "lucide-react-native";
import { cn } from "@/lib/utils";

export interface CouponPalette {
  surface: string;
  border: string;
  code: string;
  icon: string;
  accent: string;
  accentLabel: string;
}

export const COUPON_DEFAULT_PALETTE: CouponPalette = {
  surface: "#FFFFFF",
  border: "#E4E4E7",
  code: "#18181B",
  icon: "#71717A",
  accent: "#FFE4E6",
  accentLabel: "#BE123C",
};

export const COUPON_EMERALD_PALETTE: CouponPalette = {
  surface: "#FFFFFF",
  border: "#E4E4E7",
  code: "#18181B",
  icon: "#71717A",
  accent: "#D1FAE5",
  accentLabel: "#047857",
};

export const COUPON_INK_PALETTE: CouponPalette = {
  surface: "#18181B",
  border: "#3F3F46",
  code: "#FAFAFA",
  icon: "#A1A1AA",
  accent: "#FAFAFA",
  accentLabel: "#18181B",
};

type CouponOrientation = "horizontal" | "vertical";
type CouponBorderStyle = "dashed" | "solid";

interface CouponContextValue {
  palette: CouponPalette;
  orientation: CouponOrientation;
  border: CouponBorderStyle;
  borderWidth: number;
}

const CouponContext = createContext<CouponContextValue | null>(null);

function useCouponContext(component: string): CouponContextValue {
  const ctx = useContext(CouponContext);
  if (!ctx) throw new Error(`<${component} /> must be rendered inside a <Coupon> root.`);
  return ctx;
}

export interface CouponProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
  palette?: Partial<CouponPalette>;
  orientation?: CouponOrientation;
  border?: CouponBorderStyle;
  borderWidth?: number;
  radius?: number;
  /** Shorthand fields — used only when no `children` are composed. */
  code?: string;
  discount?: string;
  description?: string;
  expires?: string;
}

export function Coupon({
  className,
  children,
  palette,
  orientation = "horizontal",
  border = "dashed",
  borderWidth = 2,
  radius = 10,
  code,
  discount,
  description,
  expires,
  ...props
}: CouponProps) {
  const mergedPalette = useMemo<CouponPalette>(() => ({ ...COUPON_DEFAULT_PALETTE, ...palette }), [palette]);
  const contextValue = useMemo<CouponContextValue>(
    () => ({ palette: mergedPalette, orientation, border, borderWidth }),
    [mergedPalette, orientation, border, borderWidth]
  );
  const hasShorthand = !children && (discount !== undefined || code !== undefined);

  return (
    <CouponContext.Provider value={contextValue}>
      <View
        className={cn("overflow-hidden", orientation === "vertical" ? "flex-col" : "flex-row", className)}
        style={{
          backgroundColor: mergedPalette.surface,
          borderColor: mergedPalette.border,
          borderStyle: border,
          borderWidth,
          borderRadius: radius,
        }}
        accessibilityRole="summary"
        {...props}
      >
        {hasShorthand ? (
          <>
            <CouponDiscount>
              {discount}
              {description ? (
                <Text className="text-xs font-normal normal-case tracking-normal">{`\n${description}`}</Text>
              ) : null}
            </CouponDiscount>
            <CouponCode>
              {code}
              {expires ? (
                <Text className="text-[10px] font-normal tracking-normal opacity-70">{`\n Expires ${expires}`}</Text>
              ) : null}
            </CouponCode>
          </>
        ) : (
          children
        )}
      </View>
    </CouponContext.Provider>
  );
}

export interface CouponSectionProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
  accented?: boolean;
}

export function CouponSection({ className, accented, children, ...props }: CouponSectionProps) {
  const { palette } = useCouponContext("CouponSection");
  return (
    <View
      className={cn("flex-1 items-center justify-center px-3 py-3", className)}
      style={accented ? { backgroundColor: palette.accent } : undefined}
      {...props}
    >
      {children}
    </View>
  );
}

export interface CouponDividerProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
}

/** The tear-line perforation between two `CouponSection`s. Decorative — hidden from accessibility. */
export function CouponDivider({ className, ...props }: CouponDividerProps) {
  const { palette, orientation, border, borderWidth } = useCouponContext("CouponDivider");
  return (
    <View
      className={cn(
        orientation === "vertical" ? "w-full self-stretch border-t" : "h-full self-stretch border-l",
        border === "dashed" ? "border-dashed" : "border-solid",
        className
      )}
      style={{
        borderColor: palette.border,
        ...(orientation === "vertical" ? { borderTopWidth: borderWidth } : { borderLeftWidth: borderWidth }),
      }}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      {...props}
    />
  );
}

export interface CouponDiscountProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  textClassName?: string;
  numberOfLines?: number;
  children?: React.ReactNode;
}

/** The accent-colored discount panel. Carries the tear-line on its own trailing edge. */
export function CouponDiscount({ className, textClassName, numberOfLines, children, ...props }: CouponDiscountProps) {
  const { palette, orientation, border, borderWidth } = useCouponContext("CouponDiscount");
  return (
    <View
      className={cn(
        "flex-1 items-center justify-center px-3 py-3",
        orientation === "vertical" ? "border-b" : "border-r",
        border === "dashed" ? "border-dashed" : "border-solid",
        className
      )}
      style={{
        backgroundColor: palette.accent,
        borderColor: palette.border,
        ...(orientation === "vertical" ? { borderBottomWidth: borderWidth } : { borderRightWidth: borderWidth }),
      }}
      {...props}
    >
      <Text
        numberOfLines={numberOfLines}
        className={cn("text-center text-lg font-bold uppercase tracking-wide", textClassName)}
        style={{ color: palette.accentLabel }}
      >
        {children}
      </Text>
    </View>
  );
}

export interface CouponCodeProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  textClassName?: string;
  numberOfLines?: number;
  icon?: React.ReactNode;
  children?: React.ReactNode;
}

/** The code panel. Renders a ticket glyph beside a monospaced promo code. */
export function CouponCode({ className, textClassName, numberOfLines, icon, children, ...props }: CouponCodeProps) {
  const { palette } = useCouponContext("CouponCode");
  return (
    <View className={cn("flex-1 flex-row items-center justify-center gap-2 px-3 py-3", className)} {...props}>
      {icon ?? <Ticket size={16} color={palette.icon} strokeWidth={2} />}
      <Text
        numberOfLines={numberOfLines}
        className={cn("font-mono text-sm font-semibold tracking-widest", textClassName)}
        style={{ color: palette.code }}
      >
        {children}
      </Text>
    </View>
  );
}