Gradient or photo promo card with eyebrow kicker, title, subtitle, and a glass CTA pill — the signature moment for home feeds.
components/ui/promo-banner/promo-banner.tsx@lumioui/core@latestreact-native-svg@*| title | string | |
| eyebrow? | string | Small uppercase kicker above the title. |
| subtitle? | string | |
| ctaLabel? | string | CTA pill label — renders a white glass button. |
| onCta? | (e: GestureResponderEvent) => void | Promise<unknown> | |
| icon? | IconName | Decorative icon tile pinned top-right. |
| imageUrl? | string | Photo art instead of the gradient — dark scrim keeps copy readable. |
| from? | string | Gradient colors (hex) — raw hex is deliberate art direction, same in both schemes, same contract as StoryChrome. |
| to? | string | |
| angle? | "horizontal" | "vertical" | |
| onPress? | PressableProps["onPress"] | Whole-card press (e.g. tap-through promos with no inline CTA). |
| className? | string |
Gradient or photo promo card with eyebrow kicker, title, subtitle, and a glass CTA pill — the signature moment for home feeds.
import { PromoBanner } from "@/components/ui/promo-banner/promo-banner";
export function Example() {
return (
<PromoBanner />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Image, Text, View, type GestureResponderEvent, type PressableProps, type ViewProps, } from "react-native";
import Svg, { Defs, LinearGradient, Stop, Rect } from "react-native-svg";
import { cn, useHaptic, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
import { Glass } from "../glass";
export interface PromoBannerProps extends Omit<ViewProps, "className"> {
/** Small uppercase kicker above the title. */
eyebrow?: string;
title: string;
subtitle?: string;
/** CTA pill label — renders a white glass button. */
ctaLabel?: string;
onCta?: (e: GestureResponderEvent) => void | Promise<unknown>;
/** Decorative icon tile pinned top-right. */
icon?: IconName;
/** Photo art instead of the gradient — dark scrim keeps copy readable. */
imageUrl?: string;
/** Gradient colors (hex) — raw hex is deliberate art direction, same in
* both schemes, same contract as StoryChrome. */
from?: string;
to?: string;
angle?: "horizontal" | "vertical";
/** Whole-card press (e.g. tap-through promos with no inline CTA). */
onPress?: PressableProps["onPress"];
className?: string;
}
/**
* PromoBanner — gradient or photo promo card with eyebrow/title/CTA.
* The "signature moment" block for home feeds, deals, and announcements.
*/
export const PromoBanner = forwardRef<React.ElementRef<typeof View>, PromoBannerProps>(
function PromoBanner(
{
eyebrow,
title,
subtitle,
ctaLabel,
onCta,
icon,
imageUrl,
from = "#4169E1",
to = "#7C5CFC",
angle = "horizontal",
onPress,
className,
...props
},
ref
) {
const haptics = useHaptic();
const gradId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const horizontal = angle === "horizontal";
const cardPress = usePressAnimation({ pressedScale: 0.98 });
const ctaPress = usePressAnimation({ pressedScale: 0.94 });
const surface = (
<>
{imageUrl ? (
<>
<Image
source={{ uri: imageUrl }}
resizeMode="cover"
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
accessible={false}
/>
{/* Bottom scrim so white copy survives bright photos. */}
<Svg
width="100%"
height="100%"
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
accessible={false}
>
<Defs>
<LinearGradient id={`${gradId}-scrim`} x1="0" y1="0" x2="0" y2="1">
<Stop offset={0} stopColor="#000" stopOpacity={0.06} />
<Stop offset={1} stopColor="#000" stopOpacity={0.6} />
</LinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradId}-scrim)`} />
</Svg>
</>
) : (
<>
<Svg
width="100%"
height="100%"
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
accessible={false}
>
<Defs>
<LinearGradient
id={`${gradId}-fill`}
x1="0"
y1="0"
x2={horizontal ? 1 : 0}
y2={horizontal ? 0 : 1}
>
<Stop offset={0} stopColor={from} />
<Stop offset={1} stopColor={to} />
</LinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradId}-fill)`} />
</Svg>
{/* Tint disc — breaks the flat gradient wash. */}
<View
className="absolute -right-6 -top-8 rounded-full bg-white/15"
style={{ width: 120, height: 120 }}
/>
</>
)}
{icon ? (
<Glass
effect="clear"
sheen={0.6}
className="absolute right-4 top-4 h-11 w-11 items-center justify-center rounded-2xl"
>
<Icon name={icon} size="md" color="#fff" />
</Glass>
) : null}
</>
);
const copy = (
<View className="gap-1 p-5">
{eyebrow ? (
<Text className="text-[10px] font-bold uppercase tracking-[0.14em] text-white/75">
{eyebrow}
</Text>
) : null}
<Text className="pr-12 text-lg font-bold leading-6 text-white">{title}</Text>
{subtitle ? (
<Text className="text-sm leading-5 text-white/80">{subtitle}</Text>
) : null}
{ctaLabel ? (
<AnimatedPressable
onPress={(e) => {
haptics.light();
onCta?.(e);
}}
onPressIn={ctaPress.onPressIn}
onPressOut={ctaPress.onPressOut}
style={ctaPress.animatedStyle}
hitSlop={4}
className="mt-2.5 h-9 flex-row items-center gap-1 self-start rounded-full bg-white px-3.5"
accessibilityRole="button"
accessibilityLabel={ctaLabel}
>
{/* Pill is always white glass, so its ink is always dark — a
fixed value, not a token that flips under dark mode. */}
<Text className="text-sm font-semibold" style={{ color: "#0A0A0B" }}>
{ctaLabel}
</Text>
<Icon name="chevron-right" size="sm" color="#0A0A0B" />
</AnimatedPressable>
) : null}
</View>
);
if (onPress) {
return (
<AnimatedPressable
ref={ref}
onPress={(e) => {
haptics.light();
onPress(e);
}}
onPressIn={cardPress.onPressIn}
onPressOut={cardPress.onPressOut}
style={cardPress.animatedStyle}
className={cn("overflow-hidden rounded-3xl", className)}
accessibilityRole="button"
{...(props as Omit<PressableProps, "style">)}
>
{surface}
{copy}
</AnimatedPressable>
);
}
return (
<View ref={ref} className={cn("overflow-hidden rounded-3xl", className)} {...props}>
{surface}
{copy}
</View>
);
}
);
PromoBanner.displayName = "PromoBanner";