Kinetik UI

Glint Card

.md

A card with a specular glint that slides with the finger or the tilt of the device.

v1.0.0Live on web@shopify/react-native-skiareact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add glint-card

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/glint-card.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/glint-card.tsx
    View source files

Usage

import { GlintCard } from '@/components/kinetik/glint-card';

export function Pass() {
  return (
    <GlintCard accessibilityLabel="Night pass, gate 4">
      <Text>Night pass</Text>
    </GlintCard>
  );
}

The light follows the finger while it touches the card and the device tilt the rest of the time. The touch tracking never claims the gesture, so a GlintCard inside a ScrollView still scrolls normally.

When to use

  • A hero object the user is meant to pick up and look at: a pass, a ticket, a membership or achievement card.
  • One or two cards per screen, where the reflection adds a sense of material.

When not to use

  • Long lists of cards. Every live card runs its own sensor; set reduceEffects or pass active={false} to cards that are off-screen.
  • Cards whose main job is navigation. Pair the glint with a press response (see Press Jelly) instead of relying on it as feedback.

Notes

  • With source="tilt" or "both" the card registers the gravity sensor while mounted and active. Unmounting it, or setting active={false}, unregisters the sensor.
  • The neutral pose recentres to however the user is holding the device, so the light always starts at rest.
  • Content is rendered beneath the glint. On dark surfaces the glint only brightens; on light surfaces (where white light cannot brighten white) it shades the card by up to 7 % away from the light instead, so check text contrast against a slightly darker card.
  • Tilt changes too small to see are ignored, so a phone lying still does not redraw the card.

Props

GlintCardProps

PropTypeDefaultDescription
childrenReactNode—
sourceGlintSource'both'What moves the light. Defaults to `both`: finger while touching, device tilt otherwise.
intensitynumber0.7Strength of the glint, 0–1. Defaults to 0.7.
glintColorstring—Colour of the reflected light. Defaults to the theme highlight.
tiltDegreesnumber4Maximum card rotation in degrees as the light moves. 0 keeps the card flat. Defaults to 4.
radiusnumber—Corner radius. Defaults to the theme `xl` radius.
reduceEffectsbooleanfalseStatic reflection with no sensor or gesture work. Use on low-power devices or in long lists. Reduced-motion users always get this.
activebooleantruePause sensor and gesture work, e.g. while the card is off-screen. Defaults to true.
styleStyleProp<ViewStyle>—
contentStyleStyleProp<ViewStyle>—Style of the inner content container (padding, layout).

Performance and accessibility

Performance

Budget
Idle: no per-frame work. Moving: three gradients redrawn by Skia on the UI thread.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.43× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 159 MB app memory. Tilt changes below the visible threshold are ignored, so a phone at rest stops redrawing (was 113 frames per 5 s before). Heaviest interaction cost in the set: three gradients and a 3D transform per frame.

Real-device measurement pending.

Accessibility

Reduced motion
Static reflection; tilt sensor and touch tracking are not started.
Screen readers
Canvas is hidden from assistive tech; pass accessibilityLabel/Role to describe the card.
Touch target
Not interactive by itself; any controls inside keep their own targets.

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/glint-card.tsx
import {
  Canvas,
  Group,
  LinearGradient,
  RadialGradient,
  RoundedRect,
  rrect,
  rect,
  vec,
  type SkSize,
} from '@shopify/react-native-skia';
import type { ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useAnimatedReaction,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

import { TiltSensor, useTilt } from '../../lib/kinetik/hooks/use-tilt';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type GlintSource = 'touch' | 'tilt' | 'both';

export type GlintCardProps = Omit<ViewProps, 'style' | 'children'> & {
  children?: ReactNode;
  /** What moves the light. Defaults to `both`: finger while touching, device tilt otherwise. */
  source?: GlintSource;
  /** Strength of the glint, 0–1. Defaults to 0.7. */
  intensity?: number;
  /** Colour of the reflected light. Defaults to the theme highlight. */
  glintColor?: string;
  /** Maximum card rotation in degrees as the light moves. 0 keeps the card flat. Defaults to 4. */
  tiltDegrees?: number;
  /** Corner radius. Defaults to the theme `xl` radius. */
  radius?: number;
  /**
   * Static reflection with no sensor or gesture work. Use on low-power devices
   * or in long lists. Reduced-motion users always get this.
   */
  reduceEffects?: boolean;
  /** Pause sensor and gesture work, e.g. while the card is off-screen. Defaults to true. */
  active?: boolean;
  style?: StyleProp<ViewStyle>;
  /** Style of the inner content container (padding, layout). */
  contentStyle?: StyleProp<ViewStyle>;
};

const REST = { x: -0.35, y: -0.45 };

/**
 * A surface with a specular glint that slides with the finger or the tilt of
 * the device, plus a rim light on the edge facing the light.
 */
export function GlintCard({
  children,
  source = 'both',
  intensity = 0.7,
  glintColor,
  tiltDegrees = 4,
  radius,
  reduceEffects = false,
  active = true,
  style,
  contentStyle,
  ...viewProps
}: GlintCardProps) {
  const theme = useKinetikTheme();
  const reduced = useReduceMotion();
  const still = reduced || reduceEffects;
  const r = radius ?? theme.radius.xl;
  const light = glintColor ?? theme.colors.highlight;
  const k = Math.max(0, Math.min(1, intensity));
  // A white glint cannot brighten a white card. On light surfaces the light is
  // shown by shading everything it does not hit, as on glossy paper.
  const paper = theme.scheme === 'light' && !glintColor;
  const shade = theme.colors.text;

  const tilt = useTilt();
  const touching = useSharedValue(false);
  const touch = useSharedValue({ x: 0, y: 0 });
  const lx = useSharedValue(REST.x);
  const ly = useSharedValue(REST.y);
  const size = useSharedValue<SkSize>({ width: 0, height: 0 });

  const followTouch = source !== 'tilt';
  const followTilt = source !== 'touch';
  const live = active && !still;

  // Pick the light target on the UI thread and ease toward it.
  useAnimatedReaction(
    () => {
      if (!live) return REST;
      if (touching.get() && followTouch) return touch.get();
      if (followTilt) return { x: REST.x + tilt.x.get() * 0.9, y: REST.y + tilt.y.get() * 0.9 };
      return REST;
    },
    (target, previous) => {
      if (previous && target.x === previous.x && target.y === previous.y) return;
      const fast = touching.get();
      lx.set(withSpring(target.x, spring(fast ? 'snappy' : 'smooth')));
      ly.set(withSpring(target.y, spring(fast ? 'snappy' : 'smooth')));
    },
    [live, followTouch, followTilt],
  );

  const gesture = Gesture.Manual()
    .withTestId('glint-card-touch')
    .enabled(live && followTouch)
    .onTouchesDown((e) => {
      const t = e.allTouches[0];
      const { width, height } = size.get();
      if (!t || width === 0 || height === 0) return;
      touch.set({ x: (t.x / width) * 2 - 1, y: (t.y / height) * 2 - 1 });
      touching.set(true);
    })
    .onTouchesMove((e) => {
      const t = e.allTouches[0];
      const { width, height } = size.get();
      if (!t || width === 0 || height === 0) return;
      touch.set({
        x: Math.max(-1.2, Math.min(1.2, (t.x / width) * 2 - 1)),
        y: Math.max(-1.2, Math.min(1.2, (t.y / height) * 2 - 1)),
      });
    })
    .onTouchesUp(() => {
      touching.set(false);
    })
    .onTouchesCancelled(() => {
      touching.set(false);
    });

  const cardStyle = useAnimatedStyle(() => {
    if (tiltDegrees === 0) return {};
    return {
      transform: [
        { perspective: 900 },
        { rotateX: `${(-(ly.get() - REST.y) * tiltDegrees).toFixed(3)}deg` },
        { rotateY: `${((lx.get() - REST.x) * tiltDegrees).toFixed(3)}deg` },
      ],
    };
  });

  const clip = useDerivedValue(() => rrect(rect(0, 0, size.get().width, size.get().height), r, r));

  // Soft hotspot where the light hits the surface.
  const hotspotCenter = useDerivedValue(() => {
    const { width, height } = size.get();
    return vec(width * (0.5 + lx.get() * 0.55), height * (0.5 + ly.get() * 0.55));
  });
  const hotspotRadius = useDerivedValue(() => Math.max(size.get().width, size.get().height) * 0.75);

  // A diagonal sheen band that sweeps across as the light moves.
  const bandStart = useDerivedValue(() => vec(0, 0));
  const bandEnd = useDerivedValue(() => vec(size.get().width, size.get().height));
  const bandPositions = useDerivedValue(() => {
    const t = Math.max(0.2, Math.min(0.8, 0.5 + lx.get() * 0.42 + ly.get() * 0.18));
    return [0, t - 0.18, t - 0.05, t - 0.012, t, t + 0.012, t + 0.05, t + 0.18, 1];
  });

  // Rim light: brighter on the edge facing the light.
  const rimStart = useDerivedValue(() => {
    const { width, height } = size.get();
    return vec(width * (0.5 + lx.get() * 0.5), height * (0.5 + ly.get() * 0.5));
  });
  const rimEnd = useDerivedValue(() => {
    const { width, height } = size.get();
    return vec(width * (0.5 - lx.get() * 0.5), height * (0.5 - ly.get() * 0.5));
  });
  const rimRect = useDerivedValue(() =>
    rrect(rect(0.5, 0.5, size.get().width - 1, size.get().height - 1), r - 0.5, r - 0.5),
  );

  const clear = withAlpha(light, 0);
  const bandColors = [
    clear,
    clear,
    withAlpha(light, 0.1 * k),
    withAlpha(light, 0.22 * k),
    withAlpha(light, 0.4 * k),
    withAlpha(light, 0.22 * k),
    withAlpha(light, 0.1 * k),
    clear,
    clear,
  ];
  const unlit = withAlpha(shade, 0);
  const dim = withAlpha(shade, 0.07 * k);
  const shadeColors = [
    dim,
    dim,
    withAlpha(shade, 0.03 * k),
    withAlpha(shade, 0.01 * k),
    unlit,
    withAlpha(shade, 0.01 * k),
    withAlpha(shade, 0.03 * k),
    dim,
    dim,
  ];

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View
        collapsable={false}
        {...viewProps}
        style={[
          styles.card,
          {
            borderRadius: r,
            backgroundColor: theme.colors.surface,
            borderColor: theme.colors.border,
            boxShadow: theme.shadow.md,
          },
          style,
          cardStyle,
        ]}
      >
        <View style={[styles.content, { padding: theme.space[5] }, contentStyle]}>{children}</View>
        {/* The wrapper lets touches through: on the web the canvas itself ignores pointerEvents. */}
        <View
          pointerEvents="none"
          style={StyleSheet.absoluteFill}
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
        >
          <Canvas style={StyleSheet.absoluteFill} onSize={size}>
            {paper ? (
              <Group clip={clip} blendMode="multiply">
                <RoundedRect rect={clip}>
                  <RadialGradient c={hotspotCenter} r={hotspotRadius} colors={[unlit, dim]} />
                </RoundedRect>
                <RoundedRect rect={clip}>
                  <LinearGradient
                    start={bandStart}
                    end={bandEnd}
                    colors={shadeColors}
                    positions={bandPositions}
                  />
                </RoundedRect>
              </Group>
            ) : (
              <Group clip={clip} blendMode="screen">
                <RoundedRect rect={clip}>
                  <RadialGradient
                    c={hotspotCenter}
                    r={hotspotRadius}
                    colors={[withAlpha(light, 0.16 * k), clear]}
                  />
                </RoundedRect>
                <RoundedRect rect={clip}>
                  <LinearGradient
                    start={bandStart}
                    end={bandEnd}
                    colors={bandColors}
                    positions={bandPositions}
                  />
                </RoundedRect>
              </Group>
            )}
            <RoundedRect rect={rimRect} style="stroke" strokeWidth={1}>
              <LinearGradient
                start={rimStart}
                end={rimEnd}
                colors={
                  paper
                    ? // The edge facing away from the light falls into shadow.
                      [withAlpha(shade, 0.02 * k), withAlpha(shade, 0.22 * k)]
                    : [withAlpha(light, 0.55 * k), withAlpha(light, 0.04 * k)]
                }
              />
            </RoundedRect>
          </Canvas>
        </View>
        {live && followTilt && <TiltSensor target={tilt} />}
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  card: {
    borderWidth: StyleSheet.hairlineWidth,
    overflow: 'hidden',
  },
  content: {
    flexGrow: 1,
  },
});