Glint Card
A card with a specular glint that slides with the finger or the tilt of the device.
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,
},
});Installation
npx kinetik-ui add glint-cardpnpm dlx kinetik-ui add glint-cardbunx kinetik-ui add glint-cardyarn dlx kinetik-ui add glint-cardThe 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
- Install dependencies
npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/glint-card.tsx
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
reduceEffectsor passactive={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 settingactive={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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | |
source | GlintSource | 'both' | What moves the light. Defaults to `both`: finger while touching, device tilt otherwise. |
intensity | number | 0.7 | Strength of the glint, 0–1. Defaults to 0.7. |
glintColor | string | — | Colour of the reflected light. Defaults to the theme highlight. |
tiltDegrees | number | 4 | Maximum card rotation in degrees as the light moves. 0 keeps the card flat. Defaults to 4. |
radius | number | — | Corner radius. Defaults to the theme `xl` radius. |
reduceEffects | boolean | false | Static reflection with no sensor or gesture work. Use on low-power devices or in long lists. Reduced-motion users always get this. |
active | boolean | true | Pause sensor and gesture work, e.g. while the card is off-screen. Defaults to true. |
style | StyleProp<ViewStyle> | — | |
contentStyle | StyleProp<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,
},
});