AniUI

Profile Card

Profile card with a cover image, overlapping avatar, location row, and a press-scale action button.

Anish

@aniui

Building beautiful React Native components, one file at a time.

Mumbai, IN

Follow
Web preview — components render natively on iOS & Android
import {
  ProfileCard,
  ProfileCardCover,
  ProfileCardAvatar,
  ProfileCardBody,
  ProfileCardHeader,
  ProfileCardBio,
  ProfileCardLocation,
  ProfileCardAction,
} from "@/components/ui/profile-card";

export function MyScreen() {
  return (
    <ProfileCard>
      <ProfileCardCover source={{ uri: "https://images.example.com/cover.jpg" }} />
      <ProfileCardAvatar source="https://images.example.com/avatar.jpg" fallback="AL" />
      <ProfileCardBody>
        <ProfileCardHeader name="Anish Lawrence" handle="@anishlp" />
        <ProfileCardBio>Building AniUI — shadcn/ui for React Native.</ProfileCardBio>
        <ProfileCardLocation label="San Francisco, CA" />
        <ProfileCardAction label="Follow" onPress={() => {}} />
      </ProfileCardBody>
    </ProfileCard>
  );
}

Installation#

npx @aniui/cli add profile-card

Usage#

import {
  ProfileCard,
  ProfileCardCover,
  ProfileCardAvatar,
  ProfileCardBody,
  ProfileCardHeader,
  ProfileCardBio,
  ProfileCardLocation,
  ProfileCardAction,
} from "@/components/ui/profile-card";

export function MyScreen() {
  return (
    <ProfileCard>
      <ProfileCardCover source={{ uri: "https://images.example.com/cover.jpg" }} />
      <ProfileCardAvatar source="https://images.example.com/avatar.jpg" fallback="AL" />
      <ProfileCardBody>
        <ProfileCardHeader name="Anish Lawrence" handle="@anishlp" />
        <ProfileCardBio>Building AniUI — shadcn/ui for React Native.</ProfileCardBio>
        <ProfileCardLocation label="San Francisco, CA" />
        <ProfileCardAction label="Follow" onPress={() => {}} />
      </ProfileCardBody>
    </ProfileCard>
  );
}

Source#

components/ui/profile-card.tsx
import React, { createContext, useContext } from "react";
import {
  View,
  Text,
  Image,
  Pressable,
  useColorScheme,
  type ImageSourcePropType,
  type GestureResponderEvent,
} from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { MapPin } from "lucide-react-native";
import { cn } from "@/lib/utils";

// Signature-look defaults, close to the reference design this component is
// modeled after: a warm outline frame, a short cover band, and an avatar that
// straddles the cover/body seam near the top-right corner.
const CARD_RADIUS = 34;
const OUTLINE_WIDTH = 10;
const COVER_HEIGHT = 108;
const COVER_BOTTOM_RADIUS = 20;
const ACTION_RADIUS = 14;
const AVATAR_SIZE = 60;
const AVATAR_RADIUS = 18;
const AVATAR_RING = 3;
const AVATAR_INSET = 16;
const AVATAR_OVERLAP = 0.45;

export interface ProfileCardSpringConfig {
  damping: number;
  stiffness: number;
  mass: number;
}

const PRESS_SPRING: ProfileCardSpringConfig = { damping: 18, stiffness: 320, mass: 0.5 };

export interface ProfileCardPalette {
  surface: string;
  cover: string;
  name: string;
  handle: string;
  bio: string;
  location: string;
  action: string;
  actionLabel: string;
  avatarRing: string;
  outline: string;
}

const LIGHT_PALETTE: ProfileCardPalette = {
  surface: "#FAF7F0",
  cover: "#F4F4F5",
  name: "#18181B",
  handle: "#A1A1AA",
  bio: "#3F3F46",
  location: "#52525B",
  action: "#EDE9DD",
  actionLabel: "#18181B",
  avatarRing: "#FAF7F0",
  outline: "#FFFFFF",
};

const DARK_PALETTE: ProfileCardPalette = {
  surface: "#131315",
  cover: "#1C1C1F",
  name: "#FAFAFA",
  handle: "#71717A",
  bio: "#D4D4D8",
  location: "#A1A1AA",
  action: "#26262A",
  actionLabel: "#FAFAFA",
  avatarRing: "#131315",
  outline: "#0B0B0C",
};

interface ProfileCardContextValue {
  palette: ProfileCardPalette;
  coverHeight: number;
  springConfig: ProfileCardSpringConfig;
}

const ProfileCardContext = createContext<ProfileCardContextValue | null>(null);

function useProfileCard() {
  const ctx = useContext(ProfileCardContext);
  if (!ctx) throw new Error("ProfileCard.* components must be rendered inside <ProfileCard>");
  return ctx;
}

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

// Root — the outline-frame construction: an outer View padded by
// `outlineWidth` and filled with `palette.outline` sits behind an inner
// surface View whose radius is exactly `radius - outlineWidth`, so the two
// curves stay concentric no matter how the radius is customized.
export interface ProfileCardProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  palette?: Partial<ProfileCardPalette>;
  radius?: number;
  outlineWidth?: number;
  innerRadius?: number;
  coverHeight?: number;
  springConfig?: ProfileCardSpringConfig;
  children?: React.ReactNode;
}

export function ProfileCard({
  className,
  palette,
  radius = CARD_RADIUS,
  outlineWidth = OUTLINE_WIDTH,
  innerRadius,
  coverHeight = COVER_HEIGHT,
  springConfig = PRESS_SPRING,
  children,
  ...props
}: ProfileCardProps) {
  const isDark = useColorScheme() === "dark";
  const resolvedPalette = { ...(isDark ? DARK_PALETTE : LIGHT_PALETTE), ...palette };
  const resolvedInnerRadius = innerRadius ?? radius - outlineWidth;

  return (
    <ProfileCardContext.Provider value={{ palette: resolvedPalette, coverHeight, springConfig }}>
      <View
        className={cn("w-full", className)}
        style={{ padding: outlineWidth, borderRadius: radius, backgroundColor: resolvedPalette.outline }}
        accessibilityRole="summary"
        {...props}
      >
        <View
          className="relative"
          style={{ borderRadius: resolvedInnerRadius, backgroundColor: resolvedPalette.surface }}
        >
          {children}
        </View>
      </View>
    </ProfileCardContext.Provider>
  );
}

// Cover — only the bottom corners round (the top corners are already square
// against the surface's own rounded top edge from the outline frame above).
export interface ProfileCardCoverProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  source?: ImageSourcePropType;
  bottomRadius?: number;
  children?: React.ReactNode;
}

export function ProfileCardCover({
  className,
  source,
  bottomRadius = COVER_BOTTOM_RADIUS,
  children,
  ...props
}: ProfileCardCoverProps) {
  const { palette, coverHeight } = useProfileCard();

  return (
    <View
      className={cn("overflow-hidden", className)}
      style={{
        height: coverHeight,
        borderBottomLeftRadius: bottomRadius,
        borderBottomRightRadius: bottomRadius,
        backgroundColor: palette.cover,
      }}
      {...props}
    >
      {source ? <Image source={source} className="absolute inset-0 h-full w-full" resizeMode="cover" /> : null}
      {children}
    </View>
  );
}

// Avatar — the signature overlap math: with the default cover height (108),
// size (60) and overlap share (0.45), `top = 108 - 60*0.55 = 75`, which
// leaves 33px (55%) of the avatar sitting up inside the cover band and the
// remaining 45% hanging down into the body below.
export interface ProfileCardAvatarProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  source?: string;
  fallback?: string;
  size?: number;
  overlap?: number;
  inset?: number;
  radius?: number;
  ring?: number;
}

export function ProfileCardAvatar({
  className,
  source,
  fallback,
  size = AVATAR_SIZE,
  overlap = AVATAR_OVERLAP,
  inset = AVATAR_INSET,
  radius = AVATAR_RADIUS,
  ring = AVATAR_RING,
  ...props
}: ProfileCardAvatarProps) {
  const { palette, coverHeight } = useProfileCard();
  const top = coverHeight - size * (1 - overlap);

  return (
    <View
      className={cn("items-center justify-center overflow-hidden bg-muted", className)}
      style={{
        position: "absolute",
        top,
        right: inset,
        width: size,
        height: size,
        borderRadius: radius,
        borderWidth: ring,
        borderColor: palette.avatarRing,
        zIndex: 10,
      }}
      accessibilityRole="image"
      {...props}
    >
      {source ? (
        <Image source={{ uri: source }} className="h-full w-full" resizeMode="cover" />
      ) : (
        <Text className="text-sm font-medium text-muted-foreground">{fallback ?? "?"}</Text>
      )}
    </View>
  );
}

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

export function ProfileCardBody({ className, children, ...props }: ProfileCardBodyProps) {
  return (
    <View className={cn("gap-2.5 p-4", className)} {...props}>
      {children}
    </View>
  );
}

// Header — name + handle on a baseline-aligned row, combined into one export
// rather than three separate Header/Name/Handle pieces.
export interface ProfileCardHeaderProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  name: string;
  handle?: string;
}

export function ProfileCardHeader({ className, name, handle, ...props }: ProfileCardHeaderProps) {
  const { palette } = useProfileCard();

  return (
    <View className={cn("flex-row items-baseline gap-2", className)} {...props}>
      <Text className="shrink text-[19px] font-bold" style={{ color: palette.name }}>
        {name}
      </Text>
      {handle ? (
        <Text className="shrink text-xs" style={{ color: palette.handle }}>
          {handle}
        </Text>
      ) : null}
    </View>
  );
}

export interface ProfileCardBioProps extends React.ComponentPropsWithoutRef<typeof Text> {
  className?: string;
  children: string;
}

export function ProfileCardBio({ className, children, ...props }: ProfileCardBioProps) {
  const { palette } = useProfileCard();

  return (
    <Text className={cn("text-sm leading-5", className)} style={{ color: palette.bio }} {...props}>
      {children}
    </Text>
  );
}

// Location — icon + text row; defaults to lucide's MapPin but accepts any
// override via `icon` for a caller that wants a different glyph.
export interface ProfileCardLocationProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  label: string;
  icon?: React.ReactNode;
}

export function ProfileCardLocation({ className, label, icon, ...props }: ProfileCardLocationProps) {
  const { palette } = useProfileCard();

  return (
    <View className={cn("flex-row items-center gap-1.5", className)} {...props}>
      {icon ?? <MapPin size={14} color={palette.location} strokeWidth={2} />}
      <Text className="text-xs" style={{ color: palette.location }}>
        {label}
      </Text>
    </View>
  );
}

// Action — a Pressable that springs to 0.97 scale on press-in and back to 1
// on press-out/press-cancel, driven by react-native-reanimated.
export interface ProfileCardActionProps extends React.ComponentPropsWithoutRef<typeof Pressable> {
  className?: string;
  label: string;
  radius?: number;
}

export function ProfileCardAction({
  className,
  label,
  radius = ACTION_RADIUS,
  disabled,
  onPressIn,
  onPressOut,
  ...props
}: ProfileCardActionProps) {
  const { palette, springConfig } = useProfileCard();
  const scale = useSharedValue(1);
  const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));

  return (
    <AnimatedPressable
      className={cn("min-h-12 min-w-12 items-center justify-center py-3", disabled && "opacity-50", className)}
      style={[{ borderRadius: radius, backgroundColor: palette.action }, animatedStyle]}
      accessibilityRole="button"
      accessible
      disabled={disabled}
      onPressIn={(event: GestureResponderEvent) => {
        scale.value = withSpring(0.97, springConfig);
        onPressIn?.(event);
      }}
      onPressOut={(event: GestureResponderEvent) => {
        scale.value = withSpring(1, springConfig);
        onPressOut?.(event);
      }}
      {...props}
    >
      <Text className="text-[15px] font-semibold" style={{ color: palette.actionLabel }}>
        {label}
      </Text>
    </AnimatedPressable>
  );
}