Apple Invites
Apple Invites is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
other
What it is
Apple Invites is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion lucide-react popmotion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Apple Invites
"use client";
import { Crown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { wrap } from "popmotion";
import { useEffect, useMemo, useRef, useState } from "react";
export interface ResponsiveSize {
"2xl"?: number | string;
base?: number | string;
lg?: number | string;
md?: number | string;
sm?: number | string;
xl?: number | string;
}
const breakpoints = {
"2xl": 1536,
lg: 1024,
md: 768,
sm: 640,
xl: 1280,
} as const;
const DEFAULT_CARD_WIDTH = 240;
const DEFAULT_ASPECT_RATIO = 1.5625; // 5:8 ratio (500/320)
// Base sizes for responsive scaling (based on DEFAULT_CARD_WIDTH = 240)
const BASE_BADGE_FONT_SIZE = 12;
const BASE_BADGE_PADDING_X = 12;
const BASE_BADGE_PADDING_Y = 3;
const BASE_BADGE_ICON_SIZE = 14;
const BASE_TITLE_FONT_SIZE = 18;
const BASE_SUBTITLE_FONT_SIZE = 12;
const BASE_LOCATION_FONT_SIZE = 12;
const BASE_AVATAR_SIZE = 24;
const BASE_CONTENT_PADDING = 24;
const BASE_BADGE_TOP = 16;
const BASE_BADGE_LEFT = 16;
const BASE_BADGE_GAP = 8;
const BASE_AVATAR_GAP = 8;
const BASE_AVATAR_MARGIN_BOTTOM = 8;
const BASE_TITLE_MARGIN_BOTTOM = 4;
const BASE_LINE_HEIGHT = 1.4;
const BADGE_PADDING_Y_SCALE_FACTOR = 0.7; // Reduce vertical padding scaling for more compact badges
// Minimum sizes to ensure readability
const MIN_BADGE_FONT_SIZE = 10;
const MIN_BADGE_PADDING_X = 8;
const MIN_BADGE_PADDING_Y = 1;
const MIN_BADGE_ICON_SIZE = 12;
const MIN_TITLE_FONT_SIZE = 14;
const MIN_SUBTITLE_FONT_SIZE = 10;
const MIN_LOCATION_FONT_SIZE = 10;
const MIN_AVATAR_SIZE = 20;
const MIN_CONTENT_PADDING = 12;
const MIN_BADGE_TOP = 8;
const MIN_BADGE_LEFT = 8;
const MIN_BADGE_GAP = 4;
const MIN_AVATAR_GAP = 4;
const MIN_AVATAR_MARGIN_BOTTOM = 4;
const MIN_TITLE_MARGIN_BOTTOM = 2;
function formatSize(size: number | string): string {
return typeof size === "number" ? `${size}px` : size;
}
function getInitialSize(
size: number | string | ResponsiveSize | undefined,
defaultValue: number | string
): string {
if (!size) {
return formatSize(defaultValue);
}
if (typeof size === "number" || typeof size === "string") {
return formatSize(size);
}
// Responsive object - start with base or first available value
if (size.base !== undefined) {
return formatSize(size.base);
}
return formatSize(defaultValue);
}
function getSizeForBreakpoint(
size: ResponsiveSize,
width: number
): number | string | undefined {
if (width >= breakpoints["2xl"]) {
return size["2xl"] ?? size.xl ?? size.lg ?? size.md ?? size.sm ?? size.base;
}
if (width >= breakpoints.xl) {
return size.xl ?? size.lg ?? size.md ?? size.sm ?? size.base;
}
if (width >= breakpoints.lg) {
return size.lg ?? size.md ?? size.sm ?? size.base;
}
if (width >= breakpoints.md) {
return size.md ?? size.sm ?? size.base;
}
if (width >= breakpoints.sm) {
return size.sm ?? size.base;
}
return size.base;
}
function useResponsiveSize(
size: number | string | ResponsiveSize | undefined,
defaultValue: number | string
): string {
const [currentSize, setCurrentSize] = useState<string>(() =>
getInitialSize(size, defaultValue)
);
useEffect(() => {
if (!size || typeof size === "number" || typeof size === "string") {
return;
}
const updateSize = () => {
const width = window.innerWidth;
const selectedSize = getSizeForBreakpoint(size, width);
if (selectedSize !== undefined) {
const newSize = formatSize(selectedSize);
setCurrentSize(newSize);
}
};
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, [size]);
return currentSize;
}
function parseSize(size: string): number {
const num = Number.parseFloat(size);
return Number.isNaN(num) ? 0 : num;
}
function calculateHeightFromWidth(width: string, aspectRatio: number): string {
const widthNum = parseSize(width);
if (widthNum === 0) {
return width;
}
const heightNum = widthNum * aspectRatio;
return `${heightNum}px`;
}
export interface Participant {
avatar: string;
}
export interface Event {
backgroundClassName?: string;
badge?: string;
id: number;
image?: string;
location: string;
participants?: Participant[];
subtitle?: string;
title?: string;
}
export interface AppleInvitesProps {
activeIndex?: number;
aspectRatio?: number;
cardClassName?: string;
cardHeight?: number | string | ResponsiveSize;
cardWidth?: number | string | ResponsiveSize;
className?: string;
events: Event[];
interval?: number;
onChange?: (index: number) => void;
}
export default function AppleInvites({
events,
interval = 3000,
className = "",
cardClassName = "",
activeIndex: controlledIndex,
onChange,
cardWidth = DEFAULT_CARD_WIDTH,
cardHeight,
aspectRatio = DEFAULT_ASPECT_RATIO,
}: AppleInvitesProps) {
const shouldReduceMotion = useReducedMotion();
const [internalPage, setInternalPage] = useState(0);
const [direction, setDirection] = useState(0);
const responsiveWidth = useResponsiveSize(cardWidth, DEFAULT_CARD_WIDTH);
const variants = useMemo(
() => ({
center: {
opacity: 1,
rotate: 0,
scale: 1,
transition: shouldReduceMotion
? { duration: 0 }
: {
damping: 30,
duration: 0.25,
stiffness: 300,
type: "spring" as const,
},
x: "-50%",
zIndex: 3,
},
hidden: {
opacity: 0,
transition: shouldReduceMotion ? { duration: 0 } : { duration: 0.3 },
zIndex: 1,
},
left: {
opacity: 0.8,
rotate: -12,
scale: 0.9,
transition: shouldReduceMotion
? { duration: 0 }
: {
damping: 30,
duration: 0.25,
stiffness: 300,
type: "spring" as const,
},
x: "-130%",
zIndex: 2,
},
right: {
opacity: 0.8,
rotate: 12,
scale: 0.9,
transition: shouldReduceMotion
? { duration: 0 }
: {
damping: 30,
duration: 0.25,
stiffness: 300,
type: "spring" as const,
},
x: "30%",
zIndex: 2,
},
}),
[shouldReduceMotion]
);
const explicitHeight = useResponsiveSize(
cardHeight,
calculateHeightFromWidth(responsiveWidth, aspectRatio)
);
const [calculatedHeight, setCalculatedHeight] = useState<string>(() =>
calculateHeightFromWidth(responsiveWidth, aspectRatio)
);
// Update calculated height when width changes (if using aspect ratio)
useEffect(() => {
if (cardHeight === undefined) {
setCalculatedHeight(
calculateHeightFromWidth(responsiveWidth, aspectRatio)
);
}
}, [responsiveWidth, aspectRatio, cardHeight]);
const responsiveHeight =
cardHeight === undefined ? calculatedHeight : explicitHeight;
// Calculate responsive sizes based on card width
const cardWidthNum = parseSize(responsiveWidth);
const scaleFactor = cardWidthNum / DEFAULT_CARD_WIDTH;
// Responsive sizes for internal content
const badgeFontSize = Math.max(
MIN_BADGE_FONT_SIZE,
Math.round(BASE_BADGE_FONT_SIZE * scaleFactor)
);
const badgePaddingX = Math.max(
MIN_BADGE_PADDING_X,
Math.round(BASE_BADGE_PADDING_X * scaleFactor)
);
// Use a more aggressive scaling for vertical padding to keep it compact
// Scale padding Y less aggressively to keep badges more compact
const badgePaddingY = Math.max(
MIN_BADGE_PADDING_Y,
Math.round(
BASE_BADGE_PADDING_Y * scaleFactor * BADGE_PADDING_Y_SCALE_FACTOR
)
);
const badgeIconSize = Math.max(
MIN_BADGE_ICON_SIZE,
Math.round(BASE_BADGE_ICON_SIZE * scaleFactor)
);
const titleFontSize = Math.max(
MIN_TITLE_FONT_SIZE,
Math.round(BASE_TITLE_FONT_SIZE * scaleFactor)
);
const subtitleFontSize = Math.max(
MIN_SUBTITLE_FONT_SIZE,
Math.round(BASE_SUBTITLE_FONT_SIZE * scaleFactor)
);
const locationFontSize = Math.max(
MIN_LOCATION_FONT_SIZE,
Math.round(BASE_LOCATION_FONT_SIZE * scaleFactor)
);
const avatarSize = Math.max(
MIN_AVATAR_SIZE,
Math.round(BASE_AVATAR_SIZE * scaleFactor)
);
const contentPadding = Math.max(
MIN_CONTENT_PADDING,
Math.round(BASE_CONTENT_PADDING * scaleFactor)
);
const badgeTop = Math.max(
MIN_BADGE_TOP,
Math.round(BASE_BADGE_TOP * scaleFactor)
);
const badgeLeft = Math.max(
MIN_BADGE_LEFT,
Math.round(BASE_BADGE_LEFT * scaleFactor)
);
const badgeGap = Math.max(
MIN_BADGE_GAP,
Math.round(BASE_BADGE_GAP * scaleFactor)
);
const avatarGap = Math.max(
MIN_AVATAR_GAP,
Math.round(BASE_AVATAR_GAP * scaleFactor)
);
const avatarMarginBottom = Math.max(
MIN_AVATAR_MARGIN_BOTTOM,
Math.round(BASE_AVATAR_MARGIN_BOTTOM * scaleFactor)
);
const titleMarginBottom = Math.max(
MIN_TITLE_MARGIN_BOTTOM,
Math.round(BASE_TITLE_MARGIN_BOTTOM * scaleFactor)
);
const page = controlledIndex === undefined ? internalPage : controlledIndex;
const setPage = (val: number, dir: number) => {
if (onChange) {
onChange(val);
} else {
setInternalPage(val);
setDirection(dir);
}
};
const activeIndex = wrap(0, events.length, page);
const setPageRef = useRef(setPage);
useEffect(() => {
setPageRef.current = setPage;
});
useEffect(() => {
const timer = setInterval(() => {
setPageRef.current(page + 1, 1);
}, interval);
return () => clearInterval(timer);
}, [page, interval]);
const visibleEvents = [-1, 0, 1].map(
(offset) => events[wrap(0, events.length, activeIndex + offset)]
);
const getVariant = (index: number) => {
if (index === 1) {
return "center";
}
if (index === 0) {
return "left";
}
return "right";
};
const renderBackground = (event: Event) => {
if (event.backgroundClassName) {
return <div className={`h-full w-full ${event.backgroundClassName}`} />;
}
if (event.image) {
return (
<img
alt={event.title || ""}
className="h-full w-full object-cover"
draggable={false}
height={400}
src={event.image}
width={400}
/>
);
}
return null;
};
return (
<div
className={`relative flex h-full w-full items-center justify-center ${className}`}
>
<AnimatePresence custom={direction} initial={false}>
{visibleEvents.map((event, index) => (
<motion.div
animate={getVariant(index)}
className={`absolute top-1/2 left-1/2 origin-center -translate-y-1/2 ${cardClassName}`}
custom={direction}
exit="hidden"
initial="hidden"
key={event.id}
style={{
height: responsiveHeight,
width: responsiveWidth,
}}
variants={variants}
>
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-primary">
{renderBackground(event)}
{/* Badge */}
<div
className="absolute z-3"
style={{
left: `${badgeLeft}px`,
top: `${badgeTop}px`,
}}
>
<span
className="flex flex-row items-center rounded-full bg-black/30 font-medium text-white backdrop-blur-xl"
style={{
fontSize: `${badgeFontSize}px`,
gap: `${badgeGap}px`,
paddingBottom: `${badgePaddingY}px`,
paddingLeft: `${badgePaddingX}px`,
paddingRight: `${badgePaddingX}px`,
paddingTop: `${badgePaddingY}px`,
}}
>
<Crown size={badgeIconSize} />
{event.badge}
</span>
</div>
{/* Content */}
<div
className="absolute bottom-0 z-3 w-full rounded-b-3xl text-white"
style={{ padding: `${contentPadding}px` }}
>
{/* Participant Avatars */}
<div
className="mx-auto flex items-center justify-center"
style={{
gap: `${avatarGap}px`,
marginBottom: `${avatarMarginBottom}px`,
}}
>
{event.participants?.map((participant, idx) => (
<img
alt={`Participant ${idx + 1}`}
className="rounded-full"
draggable={false}
height={avatarSize}
key={`participant-${participant.avatar}-${idx}`}
src={participant.avatar}
style={{
height: `${avatarSize}px`,
width: `${avatarSize}px`,
}}
width={avatarSize}
/>
))}
</div>
{event.title ? (
<p
className="wrap-break-word text-center font-bold"
style={{
fontSize: `${titleFontSize}px`,
lineHeight: BASE_LINE_HEIGHT,
marginBottom: `${titleMarginBottom}px`,
}}
>
{event.title}
</p>
) : null}
{event.subtitle ? (
<p
className="wrap-break-word text-center opacity-90"
style={{
fontSize: `${subtitleFontSize}px`,
lineHeight: BASE_LINE_HEIGHT,
}}
>
{event.subtitle}
</p>
) : null}
<p
className="wrap-break-word text-center opacity-90"
style={{
fontSize: `${locationFontSize}px`,
lineHeight: BASE_LINE_HEIGHT,
}}
>
{event.location}
</p>
</div>
<div className="fixed inset-x-0 bottom-0 isolate z-2 h-1/2">
<div className="gradient-mask-t-0 absolute inset-0 overflow-hidden rounded-3xl backdrop-blur-[1px]" />
<div className="gradient-mask-t-0 absolute inset-0 overflow-hidden rounded-3xl backdrop-blur-[2px]" />
<div className="gradient-mask-t-0 absolute inset-0 overflow-hidden rounded-3xl backdrop-blur-[3px]" />
<div className="gradient-mask-t-0 absolute inset-0 overflow-hidden rounded-3xl backdrop-blur-[6px]" />
<div className="gradient-mask-t-0 absolute inset-0 overflow-hidden rounded-3xl backdrop-blur-[12px]" />
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
}