Ink Check
A checkbox whose tick is inked in one stroke while the box fills, with an optional strike-through label.
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type InkCheckProps = {
/** The line beside the box. It names the checkbox for screen readers. */
label: string;
/** A quieter line under the label. */
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
/** Strike the label through when checked, as in a to-do list. Defaults to false. */
strike?: boolean;
/** Box fill when checked. Defaults to the theme azure. */
color?: string;
/** A tick as it checks. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const BOX = 22;
const BORDER = 1.5;
const STROKE = 2.5;
/** The tick as two strokes in the box's 22 pt space: down to the elbow, then up. */
const ELBOW = { x: 9.25, y: 15 };
const SHORT = { x: 5.5, y: 11.25, length: Math.hypot(ELBOW.x - 5.5, ELBOW.y - 11.25) };
const LONG = { x: 16.75, y: 6.75 };
const LONG_LENGTH = Math.hypot(LONG.x - ELBOW.x, LONG.y - ELBOW.y);
const SHORT_ANGLE = Math.atan2(ELBOW.y - SHORT.y, ELBOW.x - SHORT.x);
const LONG_ANGLE = Math.atan2(LONG.y - ELBOW.y, LONG.x - ELBOW.x);
/** Share of the stroke spent on the short leg, so the pen moves at one speed. */
const SPLIT = SHORT.length / (SHORT.length + LONG_LENGTH);
/**
* A checkbox whose tick is inked in one stroke: down to the elbow, then up,
* at the speed of a pen. The box fills behind it with a small press, and the
* label can be struck through like a finished to-do.
*/
export function InkCheck({
label,
description,
checked,
onCheckedChange,
strike = false,
color,
haptics = true,
disabled = false,
testID,
style,
}: InkCheckProps) {
const { colors, font, space, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const ink = color ?? colors.azure;
const [labelWidth, setLabelWidth] = useState(0);
const fill = useSharedValue(checked ? 1 : 0);
const stroke = useSharedValue(checked ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
fill.set(withSpring(checked ? 1 : 0, spring('snappy', reduced)));
// The tick draws after the box has started to fill, and erases quickly.
stroke.set(
reduced
? checked
? 1
: 0
: withTiming(checked ? 1 : 0, {
duration: checked ? durations.slow : durations.quick,
easing: easings.standard,
}),
);
}, [checked, reduced, fill, stroke]);
const toggle = () => {
if (!checked) haptic('tick');
onCheckedChange(!checked);
};
const box = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(fill.get(), [0, 1], [colors.surface, ink]),
borderColor: interpolateColor(fill.get(), [0, 1], [colors.borderStrong, ink]),
transform: [{ scale: 1 - press.get() * 0.12 + Math.sin(fill.get() * Math.PI) * 0.06 }],
}));
const shortLeg = useAnimatedStyle(() => ({
width: SHORT.length * Math.min(1, stroke.get() / SPLIT),
}));
const longLeg = useAnimatedStyle(() => ({
width: LONG_LENGTH * Math.max(0, (stroke.get() - SPLIT) / (1 - SPLIT)),
}));
const strikeLine = useAnimatedStyle(() => ({ width: labelWidth * stroke.get() }));
const labelInk = useAnimatedStyle(() => ({
color: strike
? interpolateColor(stroke.get(), [0, 1], [colors.text, colors.textMuted])
: colors.text,
}));
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={toggle}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityHint={description}
accessibilityState={{ checked, disabled }}
style={[styles.row, { gap: space[3], opacity: disabled ? 0.45 : 1 }, style]}
>
<Animated.View style={[styles.box, { borderRadius: radius.xs }, box]}>
<Animated.View
style={[
styles.leg,
{
left: SHORT.x - BORDER,
top: SHORT.y - STROKE / 2 - BORDER,
backgroundColor: colors.onAzure,
},
{ transform: [{ rotate: `${SHORT_ANGLE}rad` }] },
shortLeg,
]}
/>
<Animated.View
style={[
styles.leg,
{
left: ELBOW.x - BORDER,
top: ELBOW.y - STROKE / 2 - BORDER,
backgroundColor: colors.onAzure,
},
{ transform: [{ rotate: `${LONG_ANGLE}rad` }] },
longLeg,
]}
/>
</Animated.View>
<View style={styles.text}>
<View style={styles.labelRow}>
<Animated.Text
numberOfLines={1}
onLayout={(e) => setLabelWidth(e.nativeEvent.layout.width)}
style={[{ fontSize: font.size.md, fontWeight: font.weight.medium }, labelInk]}
>
{label}
</Animated.Text>
{strike ? (
<Animated.View
pointerEvents="none"
style={[styles.strike, { backgroundColor: colors.textMuted }, strikeLine]}
/>
) : null}
</View>
{description ? (
<Text numberOfLines={2} style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{description}
</Text>
) : null}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
minHeight: minTouchTarget,
paddingVertical: 6,
},
box: { width: BOX, height: BOX, borderWidth: BORDER },
// Each leg grows from its start point; the origin keeps that point fixed while it turns.
leg: {
position: 'absolute',
height: STROKE,
borderRadius: STROKE / 2,
transformOrigin: 'left center',
},
text: { flex: 1, gap: 2 },
labelRow: { alignSelf: 'flex-start', maxWidth: '100%' },
strike: {
position: 'absolute',
left: 0,
top: '50%',
marginTop: 0.5,
height: 1.5,
borderRadius: 1,
},
});Installation
npx kinetik-ui add ink-checkpnpm dlx kinetik-ui add ink-checkbunx kinetik-ui add ink-checkyarn dlx kinetik-ui add ink-checkThe 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/ink-check.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/ink-check.tsx
Usage
import { InkCheck } from '@/components/kinetik/ink-check';
<InkCheck
label="Book the flights"
description="Lisbon, 14 to 21 June"
checked={booked}
onCheckedChange={setBooked}
/>;Set strike for to-do lists: the label is struck through and greys a little once it is checked.
When to use
- Lists of independent choices: settings, filters, a to-do or packing list.
- Consent and agreement lines in forms.
When not to use
- A setting that takes effect immediately. Use a switch, such as Pulse Toggle.
- Choosing one option from a set. Use Choice Cards or radio buttons.
Notes
- The tick is drawn by two plain views rather than a canvas, so a screen with fifty checkboxes costs no more than fifty small views.
- The tick is drawn in
onAzure. If you pass a lightcolor, check the contrast of the tick against it. - The label stays on one line so the strike-through can follow it; put detail in
description. - With reduced motion the box and tick change at once.
Props
InkCheckProps
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | The line beside the box. It names the checkbox for screen readers. |
checkedrequired | boolean | — | |
onCheckedChangerequired | (checked: boolean) => void | — | |
description | string | — | A quieter line under the label. |
strike | boolean | false | Strike the label through when checked, as in a to-do list. Defaults to false. |
color | string | — | Box fill when checked. Defaults to the theme azure. |
haptics | boolean | true | A tick as it checks. Defaults to true. |
disabled | boolean | false | |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views: the tick is two rotated bars whose widths grow. No canvas, so a long list of them stays cheap. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.15× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 147 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The box fills and the tick appears at once; the strike-through appears without sweeping.
- Screen readers
- A checkbox named by its label, with the description as its hint and a checked state.
- Touch target
- The whole row is the target, at least 44 pt tall.
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/ink-check.tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type InkCheckProps = {
/** The line beside the box. It names the checkbox for screen readers. */
label: string;
/** A quieter line under the label. */
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
/** Strike the label through when checked, as in a to-do list. Defaults to false. */
strike?: boolean;
/** Box fill when checked. Defaults to the theme azure. */
color?: string;
/** A tick as it checks. Defaults to true. */
haptics?: boolean;
disabled?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const BOX = 22;
const BORDER = 1.5;
const STROKE = 2.5;
/** The tick as two strokes in the box's 22 pt space: down to the elbow, then up. */
const ELBOW = { x: 9.25, y: 15 };
const SHORT = { x: 5.5, y: 11.25, length: Math.hypot(ELBOW.x - 5.5, ELBOW.y - 11.25) };
const LONG = { x: 16.75, y: 6.75 };
const LONG_LENGTH = Math.hypot(LONG.x - ELBOW.x, LONG.y - ELBOW.y);
const SHORT_ANGLE = Math.atan2(ELBOW.y - SHORT.y, ELBOW.x - SHORT.x);
const LONG_ANGLE = Math.atan2(LONG.y - ELBOW.y, LONG.x - ELBOW.x);
/** Share of the stroke spent on the short leg, so the pen moves at one speed. */
const SPLIT = SHORT.length / (SHORT.length + LONG_LENGTH);
/**
* A checkbox whose tick is inked in one stroke: down to the elbow, then up,
* at the speed of a pen. The box fills behind it with a small press, and the
* label can be struck through like a finished to-do.
*/
export function InkCheck({
label,
description,
checked,
onCheckedChange,
strike = false,
color,
haptics = true,
disabled = false,
testID,
style,
}: InkCheckProps) {
const { colors, font, space, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const ink = color ?? colors.azure;
const [labelWidth, setLabelWidth] = useState(0);
const fill = useSharedValue(checked ? 1 : 0);
const stroke = useSharedValue(checked ? 1 : 0);
const press = useSharedValue(0);
useEffect(() => {
fill.set(withSpring(checked ? 1 : 0, spring('snappy', reduced)));
// The tick draws after the box has started to fill, and erases quickly.
stroke.set(
reduced
? checked
? 1
: 0
: withTiming(checked ? 1 : 0, {
duration: checked ? durations.slow : durations.quick,
easing: easings.standard,
}),
);
}, [checked, reduced, fill, stroke]);
const toggle = () => {
if (!checked) haptic('tick');
onCheckedChange(!checked);
};
const box = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(fill.get(), [0, 1], [colors.surface, ink]),
borderColor: interpolateColor(fill.get(), [0, 1], [colors.borderStrong, ink]),
transform: [{ scale: 1 - press.get() * 0.12 + Math.sin(fill.get() * Math.PI) * 0.06 }],
}));
const shortLeg = useAnimatedStyle(() => ({
width: SHORT.length * Math.min(1, stroke.get() / SPLIT),
}));
const longLeg = useAnimatedStyle(() => ({
width: LONG_LENGTH * Math.max(0, (stroke.get() - SPLIT) / (1 - SPLIT)),
}));
const strikeLine = useAnimatedStyle(() => ({ width: labelWidth * stroke.get() }));
const labelInk = useAnimatedStyle(() => ({
color: strike
? interpolateColor(stroke.get(), [0, 1], [colors.text, colors.textMuted])
: colors.text,
}));
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={toggle}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
accessibilityRole="checkbox"
accessibilityLabel={label}
accessibilityHint={description}
accessibilityState={{ checked, disabled }}
style={[styles.row, { gap: space[3], opacity: disabled ? 0.45 : 1 }, style]}
>
<Animated.View style={[styles.box, { borderRadius: radius.xs }, box]}>
<Animated.View
style={[
styles.leg,
{
left: SHORT.x - BORDER,
top: SHORT.y - STROKE / 2 - BORDER,
backgroundColor: colors.onAzure,
},
{ transform: [{ rotate: `${SHORT_ANGLE}rad` }] },
shortLeg,
]}
/>
<Animated.View
style={[
styles.leg,
{
left: ELBOW.x - BORDER,
top: ELBOW.y - STROKE / 2 - BORDER,
backgroundColor: colors.onAzure,
},
{ transform: [{ rotate: `${LONG_ANGLE}rad` }] },
longLeg,
]}
/>
</Animated.View>
<View style={styles.text}>
<View style={styles.labelRow}>
<Animated.Text
numberOfLines={1}
onLayout={(e) => setLabelWidth(e.nativeEvent.layout.width)}
style={[{ fontSize: font.size.md, fontWeight: font.weight.medium }, labelInk]}
>
{label}
</Animated.Text>
{strike ? (
<Animated.View
pointerEvents="none"
style={[styles.strike, { backgroundColor: colors.textMuted }, strikeLine]}
/>
) : null}
</View>
{description ? (
<Text numberOfLines={2} style={{ color: colors.textMuted, fontSize: font.size.sm }}>
{description}
</Text>
) : null}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
minHeight: minTouchTarget,
paddingVertical: 6,
},
box: { width: BOX, height: BOX, borderWidth: BORDER },
// Each leg grows from its start point; the origin keeps that point fixed while it turns.
leg: {
position: 'absolute',
height: STROKE,
borderRadius: STROKE / 2,
transformOrigin: 'left center',
},
text: { flex: 1, gap: 2 },
labelRow: { alignSelf: 'flex-start', maxWidth: '100%' },
strike: {
position: 'absolute',
left: 0,
top: '50%',
marginTop: 0.5,
height: 1.5,
borderRadius: 1,
},
});