Kinetik UI

Profile

.md

A profile and settings screen: a turning avatar ring, stats that count up and grouped rows with switches.

v1.0.0Live on webreact-native-reanimatedreact-native-safe-area-contextreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add profile

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/profile.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets
  2. Copy the source files
    • components/kinetik/screens/profile.tsx
    View source files

Usage

import { Profile } from '@/components/kinetik/screens/profile';

<Profile
  user={{ name: user.name, handle: user.email, avatar: { uri: user.photoUrl } }}
  stats={[
    { label: 'Posts', value: 128 },
    { label: 'Followers', value: 24817 },
    { label: 'Following', value: 312 },
  ]}
  plan={{ title: 'Pro', detail: 'Renews on 3 March', onPress: () => router.push('/plan') }}
  sections={[
    {
      title: 'Account',
      rows: [
        { key: 'info', label: 'Personal info', icon: <UserIcon />, onPress: openInfo },
        { key: 'payment', label: 'Payment methods', detail: 'Visa •• 42', onPress: openCards },
      ],
    },
    {
      title: 'Preferences',
      rows: [
        {
          key: 'notifications',
          label: 'Notifications',
          toggle: { value: notify, onValueChange: setNotify },
        },
      ],
    },
  ]}
  onEdit={() => router.push('/profile/edit')}
  onSignOut={signOut}
/>;

When to use

  • The account tab of an app: who you are, a few numbers and the settings that belong to you.

When not to use

  • A public profile other people visit: it needs follow and message actions, not settings.
  • Long settings trees: link to a separate settings screen from one row instead.

Notes

  • Without avatar the initials of name are shown. Counts above 9,999 shorten to 12.4k and 1.2M.
  • A row with toggle becomes a switch; any other row is a button with a chevron. onSignOut adds a red Sign out row at the end.
  • Row icons are yours to pass, so they match the rest of your app.

Props

ProfileProps

PropTypeDefaultDescription
userrequiredProfileUser—
statsreadonly ProfileStat[][]
plan{ title: string; detail?: string; onPress?: () => void }—A highlighted card under the stats, e.g. the current subscription.
sectionsreadonly ProfileSection[]DEFAULT_SECTIONS
onEdit() => void—
onSignOut() => void—Adds a Sign out row at the end.

Performance and accessibility

Performance

Budget
The avatar ring turns on the UI thread. Stats count up for about a second after the screen opens (re-rendering on animation frames), then the screen is static.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.24× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 151 MB app memory. Animating at rest: the avatar ring keeps turning while the screen is open.

Real-device measurement pending.

Accessibility

Reduced motion
The ring holds still, stats show their final values and blocks appear without settling in.
Screen readers
The name and section titles are headers. Each stat reads as one value ("24.8k Followers"); rows are buttons with their detail as a hint, and switches are named after their row.
Touch target
Rows are 56 pt tall; buttons and switches are at least 44 pt.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/screens/profile.tsx
import { useEffect, type ReactNode } from 'react';
import {
  Image,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  View,
  type ImageSourcePropType,
} from 'react-native';
import Animated, {
  FadeInDown,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { PressJelly } from '../press-jelly';
import { PulseToggle } from '../pulse-toggle';
import { durations, easings, timing } from '../../../lib/kinetik/motion/motion';
import { useCountUp } from '../../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';
import { gradient } from '../../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../../lib/kinetik/tokens/tokens';

export type ProfileUser = {
  name: string;
  /** Line under the name, e.g. "@ada" or an email. */
  handle?: string;
  avatar?: ImageSourcePropType;
};

export type ProfileStat = { label: string; value: number };

export type ProfileRow = {
  key: string;
  label: string;
  /** Icon in the rounded tile at the start of the row. */
  icon?: ReactNode;
  /** Quiet text before the chevron, e.g. "English". */
  detail?: string;
  onPress?: () => void;
  /** Makes the row a switch instead of a link. */
  toggle?: { value: boolean; onValueChange: (value: boolean) => void };
};

export type ProfileSection = { title?: string; rows: readonly ProfileRow[] };

export type ProfileProps = {
  user: ProfileUser;
  stats?: readonly ProfileStat[];
  /** A highlighted card under the stats, e.g. the current subscription. */
  plan?: { title: string; detail?: string; onPress?: () => void };
  sections?: readonly ProfileSection[];
  onEdit?: () => void;
  /** Adds a Sign out row at the end. */
  onSignOut?: () => void;
};

const DEFAULT_SECTIONS: ProfileSection[] = [
  {
    title: 'Account',
    rows: [
      { key: 'info', label: 'Personal info' },
      { key: 'security', label: 'Password and security' },
    ],
  },
  {
    title: 'Support',
    rows: [
      { key: 'help', label: 'Help center' },
      { key: 'privacy', label: 'Privacy' },
    ],
  },
];

/** 1,234 stays as is; larger counts shorten to 12.4k and 1.2M. */
export function compactCount(n: number): string {
  if (n < 10_000) return Math.round(n).toLocaleString('en-US');
  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0).replace(/\.0$/, '')}k`;
  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
}

/**
 * A profile and settings screen: an avatar inside a slowly turning ring,
 * stats that count up, an optional plan card and grouped rows with links and
 * switches, all settling in from the top.
 */
export function Profile({
  user,
  stats = [],
  plan,
  sections = DEFAULT_SECTIONS,
  onEdit,
  onSignOut,
}: ProfileProps) {
  const { colors, space, font, radius } = useKinetikTheme();
  const insets = useSafeAreaInsets();
  const reduced = useReduceMotion();

  const enter = (step: number) =>
    reduced
      ? undefined
      : FadeInDown.delay(80 + step * 70)
          .duration(durations.slow)
          .easing(easings.standard);

  const all: ProfileSection[] = onSignOut
    ? [...sections, { rows: [{ key: 'sign-out', label: 'Sign out', onPress: onSignOut }] }]
    : [...sections];

  return (
    <ScrollView
      style={[styles.fill, { backgroundColor: colors.background }]}
      contentContainerStyle={{
        paddingTop: insets.top + space[8],
        paddingBottom: insets.bottom + space[8],
        paddingHorizontal: space[5],
        gap: space[6],
      }}
      showsVerticalScrollIndicator={false}
    >
      <Animated.View entering={enter(0)} style={[styles.head, { gap: space[3] }]}>
        <Avatar user={user} />
        <View style={{ alignItems: 'center', gap: 2 }}>
          <Text
            accessibilityRole="header"
            style={{
              color: colors.text,
              fontSize: font.size.xl,
              fontWeight: font.weight.bold,
              letterSpacing: font.tracking.tight,
            }}
          >
            {user.name}
          </Text>
          {user.handle ? (
            <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{user.handle}</Text>
          ) : null}
        </View>
        {onEdit ? (
          <PressJelly variant="surface" onPress={onEdit} style={styles.center}>
            Edit profile
          </PressJelly>
        ) : null}
      </Animated.View>

      {stats.length > 0 ? (
        <Animated.View
          entering={enter(1)}
          style={[
            styles.stats,
            {
              backgroundColor: colors.surface,
              borderColor: colors.border,
              borderRadius: radius.lg,
              paddingVertical: space[4],
            },
          ]}
        >
          {stats.map((s, i) => (
            <View key={s.label} style={styles.statSlot}>
              {i > 0 ? (
                <View style={[styles.statRule, { backgroundColor: colors.border }]} />
              ) : null}
              <Stat stat={s} />
            </View>
          ))}
        </Animated.View>
      ) : null}

      {plan ? (
        <Animated.View entering={enter(2)}>
          <Pressable
            accessibilityRole={plan.onPress ? 'button' : undefined}
            accessibilityLabel={[plan.title, plan.detail].filter(Boolean).join(', ')}
            onPress={plan.onPress}
            disabled={!plan.onPress}
            style={[
              styles.plan,
              { borderRadius: radius.lg, padding: space[4], gap: space[3] },
              gradient(`linear-gradient(120deg, ${colors.azure} 0%, ${colors.sky} 100%)`),
            ]}
          >
            <View style={styles.fill}>
              <Text
                style={{
                  color: colors.onAzure,
                  fontSize: font.size.md,
                  fontWeight: font.weight.bold,
                }}
              >
                {plan.title}
              </Text>
              {plan.detail ? (
                <Text style={{ color: colors.onAzure, fontSize: font.size.sm, opacity: 0.85 }}>
                  {plan.detail}
                </Text>
              ) : null}
            </View>
            {plan.onPress ? <Chevron color={colors.onAzure} /> : null}
          </Pressable>
        </Animated.View>
      ) : null}

      {all.map((section, i) => (
        <Animated.View
          key={section.title ?? `section-${i}`}
          entering={enter(3 + i)}
          style={{ gap: space[2] }}
        >
          {section.title ? (
            <Text
              accessibilityRole="header"
              style={{
                color: colors.textSubtle,
                fontSize: font.size.xs,
                fontWeight: font.weight.semibold,
                letterSpacing: font.tracking.wide,
                marginLeft: space[1],
              }}
            >
              {section.title.toUpperCase()}
            </Text>
          ) : null}
          <View
            style={[
              styles.group,
              {
                backgroundColor: colors.surface,
                borderColor: colors.border,
                borderRadius: radius.lg,
              },
            ]}
          >
            {section.rows.map((row, j) => (
              <Row key={row.key} row={row} first={j === 0} danger={row.key === 'sign-out'} />
            ))}
          </View>
        </Animated.View>
      ))}
    </ScrollView>
  );
}

/** The photo or initials inside a ring of colour that turns slowly. */
function Avatar({ user }: { user: ProfileUser }) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const turn = useSharedValue(0);

  useEffect(() => {
    turn.set(
      reduced
        ? 0
        : withRepeat(withTiming(1, { ...timing('slow', false, 'linear'), duration: 8000 }), -1),
    );
  }, [reduced, turn]);

  const spin = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.get() * 360}deg` }] }));
  const initials = user.name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0]?.toUpperCase())
    .join('');

  return (
    <View style={styles.avatarSlot} aria-hidden>
      <View style={styles.ring}>
        <Animated.View
          style={[
            styles.ringFill,
            gradient(
              `linear-gradient(135deg, ${colors.azure} 0%, ${colors.sky} 35%, ${colors.success} 70%, ${colors.warning} 100%)`,
            ),
            spin,
          ]}
        />
      </View>
      <View
        style={[
          styles.avatar,
          { backgroundColor: colors.surfaceRaised, borderColor: colors.background },
        ]}
      >
        {user.avatar ? (
          <Image source={user.avatar} style={styles.photo} />
        ) : (
          <Text
            style={{ color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.bold }}
          >
            {initials}
          </Text>
        )}
      </View>
    </View>
  );
}

function Stat({ stat }: { stat: ProfileStat }) {
  const { colors, font } = useKinetikTheme();
  const reduced = useReduceMotion();
  const shown = useCountUp(stat.value, { instant: reduced, duration: 1100 });
  return (
    <View
      accessible
      accessibilityLabel={`${compactCount(stat.value)} ${stat.label}`}
      style={styles.stat}
    >
      <Text
        style={{
          color: colors.text,
          fontSize: font.size.lg,
          fontWeight: font.weight.bold,
          fontVariant: ['tabular-nums'],
        }}
      >
        {compactCount(shown)}
      </Text>
      <Text style={{ color: colors.textMuted, fontSize: font.size.xs }}>{stat.label}</Text>
    </View>
  );
}

function Row({ row, first, danger }: { row: ProfileRow; first: boolean; danger: boolean }) {
  const { colors, space, font, radius } = useKinetikTheme();
  const ink = danger ? colors.danger : colors.text;

  const content = (
    <>
      {row.icon ? (
        <View
          style={[styles.tile, { backgroundColor: colors.surfaceRaised, borderRadius: radius.sm }]}
        >
          {row.icon}
        </View>
      ) : null}
      <Text style={[styles.fill, { color: ink, fontSize: font.size.md }]}>{row.label}</Text>
      {row.detail ? (
        <Text style={{ color: colors.textSubtle, fontSize: font.size.sm }}>{row.detail}</Text>
      ) : null}
    </>
  );

  const frame = [
    styles.row,
    {
      gap: space[3],
      paddingHorizontal: space[4],
      borderTopColor: colors.border,
      borderTopWidth: first ? 0 : StyleSheet.hairlineWidth,
    },
  ];

  if (row.toggle) {
    return (
      <View style={frame}>
        {content}
        <PulseToggle
          size="sm"
          value={row.toggle.value}
          onValueChange={row.toggle.onValueChange}
          accessibilityLabel={row.label}
          style={styles.center}
        />
      </View>
    );
  }
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityHint={row.detail}
      onPress={row.onPress}
      disabled={!row.onPress}
      style={({ pressed }) => [...frame, pressed && { backgroundColor: colors.surfaceSunken }]}
    >
      {content}
      {danger ? null : <Chevron color={colors.textSubtle} />}
    </Pressable>
  );
}

/** A right-pointing chevron drawn with two borders. */
function Chevron({ color }: { color: string }) {
  return <View style={[styles.chevron, { borderColor: color }]} />;
}

const styles = StyleSheet.create({
  fill: { flex: 1 },
  head: { alignItems: 'center' },
  center: { alignSelf: 'center' },
  avatarSlot: { width: 104, height: 104, alignItems: 'center', justifyContent: 'center' },
  ring: { ...StyleSheet.absoluteFill, borderRadius: 52, overflow: 'hidden' },
  ringFill: { position: 'absolute', top: -12, right: -12, bottom: -12, left: -12 },
  avatar: {
    width: 96,
    height: 96,
    borderRadius: 48,
    borderWidth: 4,
    alignItems: 'center',
    justifyContent: 'center',
    overflow: 'hidden',
  },
  photo: { width: '100%', height: '100%' },
  stats: { flexDirection: 'row', borderWidth: StyleSheet.hairlineWidth },
  statSlot: { flex: 1, flexDirection: 'row' },
  statRule: { width: StyleSheet.hairlineWidth, alignSelf: 'stretch' },
  stat: { flex: 1, alignItems: 'center', gap: 2 },
  plan: { flexDirection: 'row', alignItems: 'center', minHeight: minTouchTarget },
  group: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },
  row: { flexDirection: 'row', alignItems: 'center', minHeight: 56 },
  tile: { width: 34, height: 34, alignItems: 'center', justifyContent: 'center' },
  chevron: {
    width: 9,
    height: 9,
    borderTopWidth: 2,
    borderRightWidth: 2,
    transform: [{ rotate: '45deg' }],
    marginRight: 4,
  },
});