# Rotation Ruler

> A straighten dial: a ruler of degree ticks slides under a fixed marker and snaps to level.

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add rotation-ruler
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/rotation-ruler.json
```

## Usage

```tsx
import { RotationRuler } from '@/components/kinetik/rotation-ruler';

const [angle, setAngle] = useState(0);

<RotationRuler value={angle} onValueChange={setAngle} />;
<Image source={photo} style={{ transform: [{ rotate: `${angle}deg` }] }} />;
```

## When to use

- Straightening a photo or rotating a layer by small angles.
- Any bounded angle where feel matters more than typing a number.

## When not to use

- Full turns: a ruler of 360 ticks is slow to sweep. Use a dial or 90° buttons.
- Values that are not angles.

## Notes

- A flick glides a little before it settles on a whole degree; resting within two degrees of zero snaps to level.
- Double-tap the ruler to level it. Haptics tick on every degree and land firmer on zero.

## RotationRulerProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Angle in degrees. |
| `onValueChange`? | `(value: number) => void` |  | Called on each whole degree while dragging and as it settles. |
| `onSlidingComplete`? | `(value: number) => void` |  | Called once the ruler comes to rest. |
| `min`? | `number` | `-45` | Defaults to -45. |
| `max`? | `number` | `45` | Defaults to 45. |
| `spacing`? | `number` | `8` | Space between degree ticks, in pt. Defaults to 8. |
| `label`? | `string` | `'Rotation'` | Accessible name. Defaults to "Rotation". |
| `disabled`? | `boolean` | `false` |  |
| `haptics`? | `boolean` | `true` | A tick on every degree and a firmer one at zero. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: The ruler follows the finger but does not glide after a flick and settles without a spring.
- Screen readers: Adjustable role reading "12 degrees"; increment and decrement move by one degree.
- Touch target: The whole 56 pt panel is the touch area.

## Performance

The tick strip is static views moved by one UI-thread transform; React re-renders once per whole degree.

- Measured: Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 156 MB app memory
- Real-device measurement pending.

## Source

`components/kinetik/rotation-ruler.tsx`

```tsx
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type RotationRulerProps = {
  /** Angle in degrees. */
  value: number;
  /** Called on each whole degree while dragging and as it settles. */
  onValueChange?: (value: number) => void;
  /** Called once the ruler comes to rest. */
  onSlidingComplete?: (value: number) => void;
  /** Defaults to -45. */
  min?: number;
  /** Defaults to 45. */
  max?: number;
  /** Space between degree ticks, in pt. Defaults to 8. */
  spacing?: number;
  /** Accessible name. Defaults to "Rotation". */
  label?: string;
  disabled?: boolean;
  /** A tick on every degree and a firmer one at zero. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

/**
 * A straighten dial like a photo editor's: a ruler of degree ticks that
 * slides under a fixed marker. Flick it and it glides to a stop, snapping to
 * zero when it comes to rest close by. Double-tap to level it.
 */
export function RotationRuler({
  value,
  onValueChange,
  onSlidingComplete,
  min = -45,
  max = 45,
  spacing = 8,
  label = 'Rotation',
  disabled = false,
  haptics = true,
  style,
}: RotationRulerProps) {
  const { colors, radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const count = Math.round(max - min) + 1;

  const width = useSharedValue(0);
  const angle = useSharedValue(value);
  const start = useSharedValue(value);
  const committed = useSharedValue(Math.round(value));
  const dragging = useSharedValue(false);
  const [shown, setShown] = useState<number | null>(null);
  const now = shown ?? value;

  useEffect(() => {
    if (dragging.get()) return;
    const v = Math.min(max, Math.max(min, value));
    angle.set(reduced ? v : withSpring(v, spring('smooth')));
    committed.set(Math.round(v));
  }, [value, min, max, reduced, angle, committed, dragging]);

  const emit = (v: number) => {
    setShown(v);
    onValueChange?.(v);
    haptic(v === 0 ? 'tap' : 'tick');
  };
  const complete = (v: number) => {
    setShown(null);
    onSlidingComplete?.(v);
  };

  const track = (a: number) => {
    'worklet';
    const v = Math.round(a);
    if (v !== committed.get()) {
      committed.set(v);
      scheduleOnRN(emit, v);
    }
  };

  const settle = (target: number) => {
    'worklet';
    // Rest on a whole degree; within two degrees of level, rest on zero.
    let to = Math.round(Math.max(min, Math.min(max, target)));
    if (Math.abs(to) <= 2 && min <= 0 && max >= 0) to = 0;
    angle.set(withSpring(to, spring('smooth', reduced)));
    track(to);
    scheduleOnRN(complete, to);
  };

  const pan = Gesture.Pan()
    .withTestId('rotation-ruler')
    .enabled(!disabled)
    .activeOffsetX([-3, 3])
    .failOffsetY([-14, 14])
    .onBegin(() => {
      dragging.set(true);
      start.set(angle.get());
    })
    .onUpdate((e) => {
      const a = Math.max(min, Math.min(max, start.get() - e.translationX / spacing));
      angle.set(a);
      track(a);
    })
    .onEnd((e) => {
      dragging.set(false);
      // Carry a little of the flick before settling.
      settle(angle.get() - (reduced ? 0 : (e.velocityX / spacing) * 0.12));
    });

  const level = Gesture.Tap()
    .withTestId('rotation-ruler-reset')
    .numberOfTaps(2)
    // A finger that moves is dragging, not tapping: fail at once so the pan takes over.
    .maxDistance(10)
    .enabled(!disabled)
    .onEnd(() => settle(0));

  const strip = useAnimatedStyle(() => ({
    transform: [{ translateX: width.get() / 2 - (angle.get() - min + 0.5) * spacing }],
  }));

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (disabled) return;
    const next = committed.get() + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
    const v = Math.max(min, Math.min(max, next));
    committed.set(v);
    angle.set(withSpring(v, spring('snappy', reduced)));
    onValueChange?.(v);
    onSlidingComplete?.(v);
  };

  const fade = withAlpha(colors.surfaceRaised, 0);

  return (
    <View
      style={[
        styles.panel,
        {
          backgroundColor: colors.surfaceRaised,
          borderRadius: radius.lg,
          opacity: disabled ? 0.45 : 1,
        },
        style,
      ]}
    >
      <GestureDetector gesture={Gesture.Race(pan, level)}>
        <View
          collapsable={false}
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel={label}
          accessibilityHint="Drag to straighten"
          accessibilityValue={{ min, max, now, text: `${now} degrees` }}
          accessibilityState={{ disabled }}
          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
          onAccessibilityAction={onAccessibilityAction}
          onLayout={(e) => width.set(e.nativeEvent.layout.width)}
          style={[styles.hit, { borderRadius: radius.lg }]}
        >
          <Animated.View style={[styles.strip, strip]} pointerEvents="none">
            {Array.from({ length: count }, (_, i) => (
              <Tick key={i} degree={min + i} spacing={spacing} />
            ))}
          </Animated.View>
          <View
            pointerEvents="none"
            style={[
              StyleSheet.absoluteFill,
              gradient(
                `linear-gradient(90deg, ${colors.surfaceRaised} 0%, ${fade} 30%, ${fade} 70%, ${colors.surfaceRaised} 100%)`,
              ),
            ]}
          />
          <View pointerEvents="none" style={styles.marker}>
            <View style={[styles.dot, { backgroundColor: colors.azure }]} />
            <View style={[styles.needle, { backgroundColor: colors.text }]} />
          </View>
        </View>
      </GestureDetector>
    </View>
  );
}

/** One degree mark; every fifth is taller. The fade at the edges does the rest. */
function Tick({ degree, spacing }: { degree: number; spacing: number }) {
  const { colors } = useKinetikTheme();
  const major = degree % 5 === 0;
  return (
    <View style={[styles.tickSlot, { width: spacing }]}>
      <View
        style={[
          styles.tick,
          {
            height: major ? 16 : 9,
            backgroundColor: major ? colors.text : colors.textSubtle,
          },
        ]}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  panel: { minHeight: 56, justifyContent: 'center', overflow: 'hidden' },
  hit: { height: 56, justifyContent: 'center', overflow: 'hidden', minHeight: minTouchTarget },
  strip: { position: 'absolute', left: 0, flexDirection: 'row', alignItems: 'center', height: 56 },
  tickSlot: { alignItems: 'center', justifyContent: 'center' },
  tick: { width: 1.5, borderRadius: 1 },
  marker: {
    position: 'absolute',
    left: '50%',
    top: 8,
    bottom: 8,
    width: 6,
    marginLeft: -3,
    alignItems: 'center',
  },
  dot: { width: 4, height: 4, borderRadius: 2, marginBottom: 3 },
  needle: { width: 2, flex: 1, borderRadius: 1 },
});
```
