Unstable Lanyard

A draggable card attached to a flexible rope that swings naturally under gravity

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install @shopify/react-native-skia react-native-reanimated react-native-gesture-handler react-native-worklets expo-haptics

Copy and paste the following code into your project. component/molecules/lanyard.tsx

// @ts-nocheckimport React, { memo, useEffect } from "react";import {  View,  useWindowDimensions,  StyleSheet,  ViewStyle,  Vibration,} from "react-native";import {  Canvas,  Path,  Skia,  Image,  useImage,  RoundedRect,  LinearGradient,  vec,  Group,  Rect,  Circle,  BlurMask,  DashPathEffect,  Oval,  SkImage,} from "@shopify/react-native-skia";import {  useSharedValue,  useDerivedValue,  withSpring,  useFrameCallback,  withTiming,} from "react-native-reanimated";import { scheduleOnRN } from "react-native-worklets";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { MAX_DELTA, NUM_POINTS } from "./const";// @ts-checkimport type { ILanyard, ICardData, IPoint2D } from "./types";import { adjustBrightness } from "./helper";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";export const Lanyard: React.FC<ILanyard> & React.FunctionComponent<ILanyard> =  memo<ILanyard>(    ({      cardData = {},      cardImageSource,      gravity = 800,      stiffness = 0.9,      damping = 0.2,      iterations = 10,      ropeSegments = 10,      ropeSegmentLength = 20,      ropeThickness = 6,      ropeColor = "#E8E8E8",      ropePattern = "solid",      cardWidth = 140,      cardHeight = 200,      cardBackgroundColor = "#1E1E2E",      cardAccentColor = "#6366F1",      anchorPosition,      containerStyle = { width: 400, height: 600 } as StyleProp<ViewStyle>,      onCardPress,      onDragStart,      onDragEnd,      cardImageHeight,      cardImageWidth,    }: ILanyard): React.ReactElement & React.JSX.Element & React.ReactNode => {      const { width: screenWidth, height: screenHeight } =        useWindowDimensions();      const numSegments = Math.max(1, Math.min(ropeSegments, NUM_POINTS - 1));      const anchorX = anchorPosition?.x ?? screenWidth / 2;      const anchorY = anchorPosition?.y ?? 80;      const cardImage = useImage(        typeof cardImageSource === "string"          ? cardImageSource          : (cardImageSource ?? null),      );      const x0 = useSharedValue<number>(anchorX);      const y0 = useSharedValue<number>(anchorY);      const px0 = useSharedValue<number>(anchorX);      const py0 = useSharedValue<number>(anchorY);      const x1 = useSharedValue<number>(anchorX);      const y1 = useSharedValue<number>(anchorY + ropeSegmentLength);      const px1 = useSharedValue<number>(anchorX);      const py1 = useSharedValue<number>(anchorY + ropeSegmentLength);      const x2 = useSharedValue<number>(anchorX);      const y2 = useSharedValue<number>(anchorY + ropeSegmentLength * 2);      const px2 = useSharedValue<number>(anchorX);      const py2 = useSharedValue<number>(anchorY + ropeSegmentLength * 2);      const x3 = useSharedValue<number>(anchorX);      const y3 = useSharedValue<number>(anchorY + ropeSegmentLength * 3);      const px3 = useSharedValue<number>(anchorX);      const py3 = useSharedValue<number>(anchorY + ropeSegmentLength * 3);      const x4 = useSharedValue<number>(anchorX);      const y4 = useSharedValue<number>(anchorY + ropeSegmentLength * 4);      const px4 = useSharedValue<number>(anchorX);      const py4 = useSharedValue<number>(anchorY + ropeSegmentLength * 4);      const x5 = useSharedValue<number>(anchorX);      const y5 = useSharedValue<number>(anchorY + ropeSegmentLength * 5);      const px5 = useSharedValue<number>(anchorX);      const py5 = useSharedValue<number>(anchorY + ropeSegmentLength * 5);      const x6 = useSharedValue<number>(anchorX);      const y6 = useSharedValue<number>(anchorY + ropeSegmentLength * 6);      const px6 = useSharedValue<number>(anchorX);      const py6 = useSharedValue<number>(anchorY + ropeSegmentLength * 6);      const x7 = useSharedValue<number>(anchorX);      const y7 = useSharedValue<number>(anchorY + ropeSegmentLength * 7);      const px7 = useSharedValue<number>(anchorX);      const py7 = useSharedValue<number>(anchorY + ropeSegmentLength * 7);      const x8 = useSharedValue<number>(anchorX);      const y8 = useSharedValue<number>(anchorY + ropeSegmentLength * 8);      const px8 = useSharedValue<number>(anchorX);      const py8 = useSharedValue<number>(anchorY + ropeSegmentLength * 8);      const x9 = useSharedValue<number>(anchorX);      const y9 = useSharedValue<number>(anchorY + ropeSegmentLength * 9);      const px9 = useSharedValue<number>(anchorX);      const py9 = useSharedValue<number>(anchorY + ropeSegmentLength * 9);      const x10 = useSharedValue<number>(anchorX);      const y10 = useSharedValue<number>(anchorY + ropeSegmentLength * 10);      const px10 = useSharedValue<number>(anchorX);      const py10 = useSharedValue<number>(anchorY + ropeSegmentLength * 10);      const cardCenterX = useSharedValue<number>(anchorX);      const cardCenterY = useSharedValue<number>(        anchorY + numSegments * ropeSegmentLength + cardHeight / 2,      );      const cardRotation = useSharedValue<number>(0);      const cardScale = useSharedValue<number>(1);      const isDragging = useSharedValue<boolean>(false);      const dragStartX = useSharedValue<number>(0);      const dragStartY = useSharedValue<number>(0);      const lastTime = useSharedValue<number>(Date.now());      const segmentCount = useSharedValue<number>(numSegments);      useEffect(() => {        segmentCount.value = withTiming(numSegments);      }, [numSegments]);      useEffect(() => {        const positions = [          { x: x0, y: y0, px: px0, py: py0 },          { x: x1, y: y1, px: px1, py: py1 },          { x: x2, y: y2, px: px2, py: py2 },          { x: x3, y: y3, px: px3, py: py3 },          { x: x4, y: y4, px: px4, py: py4 },          { x: x5, y: y5, px: px5, py: py5 },          { x: x6, y: y6, px: px6, py: py6 },          { x: x7, y: y7, px: px7, py: py7 },          { x: x8, y: y8, px: px8, py: py8 },          { x: x9, y: y9, px: px9, py: py9 },          { x: x10, y: y10, px: px10, py: py10 },        ];        for (let i = 0; i <= numSegments; i++) {          const posY = anchorY + i * ropeSegmentLength;          positions[i].x.value = anchorX;          positions[i].y.value = posY;          positions[i].px.value = anchorX;          positions[i].py.value = posY;        }        cardCenterX.value = anchorX;        cardCenterY.value =          anchorY + numSegments * ropeSegmentLength + cardHeight / 2;      }, [anchorX, anchorY, numSegments, ropeSegmentLength, cardHeight]);      useFrameCallback((frameInfo) => {        "worklet";        const now = frameInfo.timestamp;        let dt = (now - lastTime.value) / 1000;        dt = Math.min(dt, MAX_DELTA);        lastTime.value = now;        if (dt <= 0) return;        const segs = segmentCount.value;        const xs = [x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10];        const ys = [y0, y1, y2, y3, y4, y5, y6, y7, y8, y9, y10];        const pxs = [px0, px1, px2, px3, px4, px5, px6, px7, px8, px9, px10];        const pys = [py0, py1, py2, py3, py4, py5, py6, py7, py8, py9, py10];        const cardAttachX = cardCenterX.value;        const cardAttachY = cardCenterY.value - cardHeight / 2;        if (!isDragging.value) {          for (let i = 1; i < segs; i++) {            const velX = (xs[i].value - pxs[i].value) * damping;            const velY = (ys[i].value - pys[i].value) * damping;            pxs[i].value = xs[i].value;            pys[i].value = ys[i].value;            xs[i].value += velX;            ys[i].value += velY + gravity * dt * dt;          }        }        xs[segs].value = cardAttachX;        ys[segs].value = cardAttachY;        pxs[segs].value = cardAttachX;        pys[segs].value = cardAttachY;        for (let iter = 0; iter < iterations; iter++) {          for (let i = 0; i < segs; i++) {            const dx = xs[i + 1].value - xs[i].value;            const dy = ys[i + 1].value - ys[i].value;            const dist = Math.sqrt(dx * dx + dy * dy);            if (dist < 0.0001) continue;            const diff = (ropeSegmentLength - dist) / dist;            const offsetX = dx * diff * 0.5 * stiffness;            const offsetY = dy * diff * 0.5 * stiffness;            if (i > 0) {              xs[i].value -= offsetX;              ys[i].value -= offsetY;            }            if (i + 1 < segs) {              xs[i + 1].value += offsetX;              ys[i + 1].value += offsetY;            }          }        }        xs[segs].value = cardAttachX;        ys[segs].value = cardAttachY;        if (!isDragging.value) {          const ropeEndX = xs[segs].value;          const ropeEndY = ys[segs].value;          cardCenterX.value = ropeEndX;          cardCenterY.value = ropeEndY + cardHeight / 2;          if (segs >= 1) {            const prevX = xs[segs - 1].value;            const prevY = ys[segs - 1].value;            const dx = ropeEndX - prevX;            const dy = ropeEndY - prevY;            const targetRot = Math.atan2(dx, dy) * 0.9;            cardRotation.value = cardRotation.value * 0.9 + targetRot * 0.1;            const pullStrength = 1;            const idealY = prevY + ropeSegmentLength;            const idealX = prevX;            cardCenterX.value =              cardCenterX.value * (1 - pullStrength) + idealX * pullStrength;            cardCenterY.value =              cardCenterY.value * (1 - pullStrength) +              (idealY + cardHeight / 2) * pullStrength;          }        }      });      const tapGesture = Gesture.Tap()        .onStart((event) => {          "worklet";          const dx = Math.abs(event.x - cardCenterX.value);          const dy = Math.abs(event.y - cardCenterY.value);          if (dx < cardWidth / 2 && dy < cardHeight / 2) {            cardScale.value = withSpring<number>(0.95, { damping: 15 });          }        })        .onEnd((event) => {          "worklet";          cardScale.value = withSpring(1, { damping: 15 });          const dx = Math.abs(event.x - cardCenterX.value);          const dy = Math.abs(event.y - cardCenterY.value);          if (dx < cardWidth / 2 && dy < cardHeight / 2 && onCardPress) {            scheduleOnRN(onCardPress);          }        });      const panGesture = Gesture.Pan()        .onStart((event) => {          "worklet";          const dx = Math.abs(event.x - cardCenterX.value);          const dy = Math.abs(event.y - cardCenterY.value);          if (dx < cardWidth / 2 + 30 && dy < cardHeight / 2 + 30) {            isDragging.value = true;            dragStartX.value = event.x - cardCenterX.value;            dragStartY.value = event.y - cardCenterY.value;            cardScale.value = withSpring(1.02, { damping: 20 });            if (onDragStart) {              scheduleOnRN(onDragStart);            }          }        })        .onUpdate((event) => {          "worklet";          if (!isDragging.value) return;          const newX = event.x - dragStartX.value;          const newY = event.y - dragStartY.value;          cardCenterX.value = Math.max(            cardWidth / 2,            Math.min(screenWidth - cardWidth / 2, newX),          );          cardCenterY.value = Math.max(            anchorY + ropeSegmentLength + cardHeight / 2,            Math.min(screenHeight - cardHeight / 2, newY),          );          cardRotation.value = event.velocityX * 0.00008;          scheduleOnRN(impactAsync, ImpactFeedbackStyle.Soft);        })        .onEnd((event) => {          "worklet";          if (!isDragging.value) return;          isDragging.value = false;          cardScale.value = withSpring<number>(1);          const segs = segmentCount.value;          const xs = [x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10];          const ys = [y0, y1, y2, y3, y4, y5, y6, y7, y8, y9, y10];          const pxs = [px0, px1, px2, px3, px4, px5, px6, px7, px8, px9, px10];          const pys = [py0, py1, py2, py3, py4, py5, py6, py7, py8, py9, py10];          const velScale = 0.012;          for (let i = Math.max(1, segs - 3); i < segs; i++) {            pxs[i].value = xs[i].value - event.velocityX * velScale;            pys[i].value = ys[i].value - event.velocityY * velScale;          }          cardRotation.value = withSpring<number>(0, {            damping: 10,            stiffness: 100,          });          if (onDragEnd) {            scheduleOnRN<[], void>(onDragEnd);          }        });      const gesture = Gesture.Simultaneous(tapGesture, panGesture);      const ropePath = useDerivedValue<string>(() => {        const segs = segmentCount.value;        const xs = [x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10];        const ys = [y0, y1, y2, y3, y4, y5, y6, y7, y8, y9, y10];        const points: IPoint2D[] = [];        for (let i = 0; i <= segs; i++) {          points.push({ x: xs[i].value, y: ys[i].value });        }        if (points.length < 2) return "";        if (points.length === 2) {          return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;        }        let path = `M ${points[0].x} ${points[0].y}`;        const tension = 0.5;        for (let i = 0; i < points.length - 1; i++) {          const p0 = points[Math.max(0, i - 1)];          const p1 = points[i];          const p2 = points[i + 1];          const p3 = points[Math.min(points.length - 1, i + 2)];          const cp1x = p1.x + ((p2.x - p0.x) * tension) / 3;          const cp1y = p1.y + ((p2.y - p0.y) * tension) / 3;          const cp2x = p2.x - ((p3.x - p1.x) * tension) / 3;          const cp2y = p2.y - ((p3.y - p1.y) * tension) / 3;          path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;        }        return path;      });      const cardTransform = useDerivedValue(() => [        { translateX: cardCenterX.value },        { translateY: cardCenterY.value },        { rotate: cardRotation.value },        { scale: cardScale.value },      ]);      return (        <View style={[styles.container, containerStyle]}>          <GestureDetector gesture={gesture}>            <Canvas style={styles.canvas}>              <Path                path={ropePath}                style="stroke"                strokeWidth={ropeThickness + 4}                strokeCap="round"                strokeJoin="round"                color="rgba(0,0,0,0.15)"              >                <BlurMask blur={4} style="normal" />              </Path>              <Path                path={ropePath}                style="stroke"                strokeWidth={ropeThickness}                strokeCap="round"                strokeJoin="round"                color={ropeColor}              />              {ropePattern === "striped" && (                <Path                  path={ropePath}                  style="stroke"                  strokeWidth={ropeThickness}                  strokeCap="round"                  strokeJoin="round"                  color={cardAccentColor}                >                  <DashPathEffect intervals={[8, 8]} />                </Path>              )}              <Group transform={cardTransform} origin={vec(0, 0)}>                <IDCard                  width={cardWidth}                  height={cardHeight}                  backgroundColor={cardBackgroundColor}                  accentColor={cardAccentColor}                  cardData={cardData}                  cardImageHeight={cardImageHeight}                  cardImageWidth={cardImageWidth}                  cardImage={cardImage}                />              </Group>              <Circle cx={anchorX} cy={anchorY} r={8} color="#333" />              <Circle cx={anchorX} cy={anchorY} r={5} color="#666" />              <Circle cx={anchorX} cy={anchorY} r={3} color="#999" />            </Canvas>          </GestureDetector>        </View>      );    },  );interface IDCardProps {  width: number;  height: number;  backgroundColor: string;  accentColor: string;  cardData: ICardData;  cardImage: SkImage | null;  cardImageHeight?: number;  cardImageWidth?: number;}const IDCard: React.FC<IDCardProps> & React.FunctionComponent<IDCardProps> = ({  width,  height,  backgroundColor,  accentColor,  cardImage,  cardImageWidth,  cardImageHeight,}: IDCardProps): React.ReactElement & React.JSX.Element & React.ReactNode => {  const halfWidth = width / 2;  const halfHeight = height / 2;  return (    <Group>      <RoundedRect        x={-halfWidth + 2}        y={-halfHeight + 2}        width={width}        height={height}        r={12}        color="rgba(0,0,0,0.3)"      >        <BlurMask blur={8} style="normal" />      </RoundedRect>      <RoundedRect        x={-halfWidth}        y={-halfHeight}        width={width}        height={height}        r={12}      >        <LinearGradient          start={vec(-halfWidth, -halfHeight)}          end={vec(halfWidth, halfHeight)}          colors={[            backgroundColor,            adjustBrightness<string, number>(backgroundColor, -15),          ]}        />      </RoundedRect>      <RoundedRect        x={-halfWidth}        y={-halfHeight}        width={width}        height={12}        r={12}        color={accentColor}      />      <Rect        x={-halfWidth}        y={-halfHeight + 6}        width={width}        height={6}        color={accentColor}      />      {cardImage ? (        <Group          clip={Skia.Path.Make().addRRect(            Skia.RRectXY(              Skia.XYWHRect(                -35,                -halfHeight + 30,                cardImageWidth ?? 70,                cardImageHeight ?? 70,              ),              35,              35,            ),          )}        >          <Image            image={cardImage}            x={-35}            y={-halfHeight + 30}            width={cardImageWidth ?? 70}            height={cardImageHeight ?? 70}            fit="cover"          />        </Group>      ) : (        <Group>          <Circle            cx={0}            cy={-halfHeight + 65}            r={35}            color={adjustBrightness<string, number>(backgroundColor, 20)}          />          <Circle            cx={0}            cy={-halfHeight + 55}            r={12}            color={adjustBrightness<string, number>(backgroundColor, 40)}          />          <Oval            x={-18}            y={-halfHeight + 65}            width={36}            height={25}            color={adjustBrightness<string, number>(backgroundColor, 40)}          />        </Group>      )}      <RoundedRect        x={-40}        y={-halfHeight + 115}        width={80}        height={12}        r={6}        color={adjustBrightness<string, number>(backgroundColor, 30)}      />      <RoundedRect        x={-30}        y={-halfHeight + 135}        width={60}        height={8}        r={4}        color={adjustBrightness<string, number>(backgroundColor, 20)}      />      {/* <RoundedRect        x={-halfWidth + 10}        y={halfHeight - 40}        width={width - 20}        height={30}        r={4}        color={adjustBrightness<string, number>(backgroundColor, 10)}      />      <Rect        x={halfWidth - 40}        y={halfHeight - 35}        width={25}        height={25}        color={adjustBrightness<string, number>(backgroundColor, 25)}      /> */}      <RoundedRect        x={-halfWidth}        y={-halfHeight}        width={width}        height={height}        r={12}        style="stroke"        strokeWidth={1}        color="rgba(255,255,255,0.1)"      />      <RoundedRect        x={-12}        y={-halfHeight - 8}        width={24}        height={16}        r={4}        color="#A0A0A0"      />      <RoundedRect        x={-10}        y={-halfHeight - 6}        width={20}        height={6}        r={3}        color="#C8C8C8"      />    </Group>  );};const styles = StyleSheet.create({  container: {    flex: 1,  },  canvas: { flex: 1 },});export default memo<React.FunctionComponent<ILanyard> & React.FC<ILanyard>>(  Lanyard,);

Usage

import { View, Text, StyleSheet, Dimensions, Pressable } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import Lanyard from "@/components/molecules/lanyard";const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"),  });  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      {/* Header */}      <View style={styles.header}>        <View>          <Text            style={[              styles.title,              { fontFamily: fontLoaded ? "Coolvetica" : undefined },            ]}          >            Lanyard          </Text>          <Text            style={[              styles.subtitle,              { fontFamily: fontLoaded ? "SfProRounded" : undefined },            ]}          >            Drag to interact          </Text>        </View>        <Pressable style={styles.iconButton} onPress={() => alert("profile")}>          <SymbolView name="person.crop.circle" size={20} tintColor="#fff" />        </Pressable>      </View>      <View        style={{          flex: 1,        }}      >        <Lanyard          cardWidth={160}          cardHeight={220}          cardBackgroundColor="#000000"          cardAccentColor="#ffffff"          ropeColor="#ededed"          ropePattern="striped"          ropeSegments={8}          ropeSegmentLength={15}          ropeThickness={3}          gravity={600}          stiffness={0.85}          damping={0.25}          cardImageSource="https://pbs.twimg.com/profile_images/2002726630008184832/_p8TfI5J_400x400.jpg"          cardImageWidth={70}          cardImageHeight={70}          anchorPosition={{            x: SCREEN_WIDTH / 2,            y: 20,          }}          cardData={{            name: "Alex Morgan",          }}          onCardPress={() => {            console.log("Card tapped!");          }}          onDragStart={() => {            console.log("Drag started");          }}          onDragEnd={() => {            console.log("Drag ended");          }}          containerStyle={styles.lanyardContainer}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingHorizontal: 24,    paddingTop: 60,  },  title: {    fontSize: 32,    fontWeight: "700",    color: "#fff",  },  subtitle: {    fontSize: 14,    color: "#666",    marginTop: 2,  },  iconButton: {    width: 44,    height: 44,    borderRadius: 22,    backgroundColor: "rgba(255,255,255,0.08)",    justifyContent: "center",    alignItems: "center",  },  lanyardContainer: {},  infoCard: {    position: "absolute",    bottom: 40,    left: 24,    right: 24,    backgroundColor: "#141414",    borderRadius: 16,    padding: 20,    gap: 16,    borderWidth: 1,    borderColor: "#222",  },  infoRow: {    flexDirection: "row",    alignItems: "center",    gap: 12,  },  infoText: {    fontSize: 15,    color: "#e0e0e0",  },});

Props

ICardData

React Native Reanimated
React Native Skia
React Native Gesture Handler
React Native Worklets
Expo Haptics