Veltrix

gestures

Orbit Composer

An adaptive quick-action composer that becomes a full orbit or edge-aware arc.

AppleiOSAndroidAndroidExpoExpo Go
hapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add expo-haptics react-native-gesture-handler react-native-reanimated

Anatomy

1 file

anatomy.tsx
<OrbitComposer>  <OrbitComposer.Action />  <OrbitComposer.Trigger /></OrbitComposer>

Install tailwind variants

pnpm add tailwind-variants

Usage

OrbitComposerExample.tsx
import { Feather } from "@expo/vector-icons";import { Text, View } from "react-native";import { OrbitComposer } from "@animations/ui/components/gestures/orbit-composer/uniwind";const actions = [  <OrbitComposer.Action    accessibilityLabel="Create photo"    id="photo"    key="photo"  >    <View className="items-center gap-0.5">      <Feather name="camera" size={18} />      <Text className="text-[9px] font-extrabold">Photo</Text>    </View>  </OrbitComposer.Action>,  <OrbitComposer.Action    accessibilityLabel="Create note"    id="note"    key="note"  >    <View className="items-center gap-0.5">      <Feather name="edit-3" size={18} />      <Text className="text-[9px] font-extrabold">Note</Text>    </View>  </OrbitComposer.Action>,];export function OrbitComposerExample() {  return (    <OrbitComposer      animation="fan"      anchor="bottom"      className="h-72 w-full"      classNames={{        actionContent: "bg-violet-100",        triggerContent: "bg-violet-700",      }}      onSelect={(id) => console.log(id)}    >      {actions}      <OrbitComposer.Trigger>        {({ open }) => (          <Feather color="#ffffff" name={open ? "x" : "plus"} size={24} />        )}      </OrbitComposer.Trigger>    </OrbitComposer>  );}

Component files

5 files

orbit-composer/config.ts
export const ORBIT_COMPOSER_CONFIG = {  actionSize: 56,  arcSpread: 128,  focusDistanceMultiplier: 0.9,  maxActions: 6,  radius: 104,  safePadding: 8,  spring: {    damping: 17,    mass: 0.72,    stiffness: 190,  },  triggerSize: 64,} as const;
orbit-composer/types.ts
import type { ReactNode } from "react";export type OrbitComposerPlacement =  | "auto"  | "bottom"  | "circle"  | "left"  | "right"  | "top";export type OrbitComposerAnimation =  | "adaptive"  | "bloom"  | "cascade"  | "fan"  | "spin";export type OrbitComposerAnchor =  | "bottom"  | "bottom-left"  | "bottom-right"  | "center"  | "left"  | "right"  | "top"  | "top-left"  | "top-right"  | OrbitComposerPosition;export type OrbitComposerAction = {  accessibilityLabel: string;  children: ReactNode;  disabled?: boolean;  id: string;};export type OrbitComposerActionProps = OrbitComposerAction;export type OrbitComposerRenderState = {  open: boolean;};export type OrbitComposerTriggerProps = {  children:    | ReactNode    | ((state: OrbitComposerRenderState) => ReactNode);};export type OrbitComposerBaseProps = {  accessibilityLabel?: string;  actionSize?: number;  animation?: OrbitComposerAnimation;  anchor?: OrbitComposerAnchor;  children: ReactNode;  defaultOpen?: boolean;  disabled?: boolean;  haptics?: boolean;  onOpenChange?: (open: boolean) => void;  onSelect?: (id: string) => void;  open?: boolean;  placement?: OrbitComposerPlacement;  radius?: number;  safePadding?: number;  triggerSize?: number;};export type OrbitComposerPosition = {  x: number;  y: number;};
orbit-composer/uniwind/index.tsx
import {  Children,  createContext,  isValidElement,  useContext,  type ReactNode,} from "react";import { Pressable } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { ORBIT_COMPOSER_CONFIG } from "../config";import type {  OrbitComposerAction,  OrbitComposerActionProps,  OrbitComposerBaseProps,  OrbitComposerPosition,  OrbitComposerRenderState,  OrbitComposerTriggerProps,} from "../types";import {  useOrbitActionAnimatedStyle,  useOrbitComposer,} from "../use-orbit-composer";import {  orbitComposerVariants,  type OrbitComposerSlot,} from "./variants";type OrbitActionProps = {  action: OrbitComposerAction;  actionSize: number;  animation: ReturnType<typeof useOrbitComposer>["animation"];  anchor: OrbitComposerPosition;  classNames?: SlotsToClasses<OrbitComposerSlot>;  focusedIndex: ReturnType<    typeof useOrbitComposer  >["focusedIndex"];  index: number;  motion: ReturnType<typeof useOrbitComposer>["motion"];  open: boolean;  position: OrbitComposerPosition;  progress: ReturnType<typeof useOrbitComposer>["progress"];  select: (index: number) => void;};function OrbitAction({  action,  actionSize,  animation,  anchor,  classNames,  focusedIndex,  index,  motion,  open,  position,  progress,  select,}: OrbitActionProps) {  const { action: actionClass, actionContent } =    orbitComposerVariants();  const animatedStyle = useOrbitActionAnimatedStyle({    animation,    anchor,    focusedIndex,    index,    motion,    position,    progress,  });  function handlePress() {    select(index);  }  return (    <Animated.View      accessibilityElementsHidden={!open}      className={actionClass({        className: classNames?.action,      })}      importantForAccessibility={        open ? "yes" : "no-hide-descendants"      }      pointerEvents={open ? "auto" : "none"}      style={[        {          height: actionSize,          left: anchor.x - actionSize / 2,          top: anchor.y - actionSize / 2,          width: actionSize,        },        animatedStyle,      ]}    >      <Pressable        accessibilityLabel={action.accessibilityLabel}        accessibilityRole="button"        accessibilityState={{ disabled: action.disabled }}        android_ripple={{          borderless: true,          color: "rgba(23,23,19,0.12)",        }}        className={actionContent({          className: classNames?.actionContent,        })}        disabled={action.disabled}        onPress={handlePress}      >        {action.children}      </Pressable>    </Animated.View>  );}export type OrbitComposerProps = OrbitComposerBaseProps & {  className?: string;  classNames?: SlotsToClasses<OrbitComposerSlot>;};const OrbitComposerTriggerContext =  createContext<OrbitComposerRenderState | null>(null);function OrbitComposerActionSlot({  children,}: OrbitComposerActionProps) {  return children;}function OrbitComposerTrigger({  children,}: OrbitComposerTriggerProps) {  const state = useContext(OrbitComposerTriggerContext);  if (!state) {    throw new Error(      "OrbitComposer.Trigger must be used inside OrbitComposer.",    );  }  return typeof children === "function" ? children(state) : children;}function OrbitComposerRoot({  accessibilityLabel = "Open quick actions",  actionSize = ORBIT_COMPOSER_CONFIG.actionSize,  animation,  anchor,  children,  className,  classNames,  defaultOpen,  disabled = false,  haptics,  onOpenChange,  onSelect,  open,  placement,  radius,  safePadding,  triggerSize = ORBIT_COMPOSER_CONFIG.triggerSize,}: OrbitComposerProps) {  const childNodes = Children.toArray(children);  const actions = childNodes.flatMap<OrbitComposerAction>(    (child) =>      isValidElement<OrbitComposerActionProps>(child) &&      child.type === OrbitComposerActionSlot        ? [child.props]        : [],  );  const triggerNode = childNodes.find(    (child) =>      isValidElement(child) && child.type === OrbitComposerTrigger,  );  const { container, trigger, triggerContent } =    orbitComposerVariants({ disabled });  const {    anchor: resolvedAnchor,    animation: resolvedAnimation,    focusedIndex,    gesture,    isOpen,    onLayout,    motion,    positions,    progress,    select,    toggle,  } = useOrbitComposer({    actionSize,    actions,    animation,    anchor,    defaultOpen,    disabled,    haptics,    onOpenChange,    onSelect,    open,    placement,    radius,    safePadding,    triggerSize,  });  return (    <Animated.View      className={container({        className: [className, classNames?.container],      })}      onLayout={onLayout}    >      {actions.map((action, index) => (        <OrbitAction          action={action}          actionSize={actionSize}          animation={resolvedAnimation}          anchor={resolvedAnchor}          classNames={classNames}          focusedIndex={focusedIndex}          index={index}          key={action.id}          motion={motion}          open={isOpen}          position={positions[index]}          progress={progress}          select={select}        />      ))}      <GestureDetector gesture={gesture}>        <Animated.View          accessibilityLabel={accessibilityLabel}          accessibilityRole="button"          accessibilityState={{            disabled,            expanded: isOpen,          }}          accessible          className={trigger({            className: classNames?.trigger,          })}          onAccessibilityTap={toggle}          style={{            height: triggerSize,            left: resolvedAnchor.x - triggerSize / 2,            top: resolvedAnchor.y - triggerSize / 2,            width: triggerSize,          }}        >          <Animated.View            className={triggerContent({              className: classNames?.triggerContent,            })}          >            <OrbitComposerTriggerContext.Provider              value={{ open: isOpen }}            >              {triggerNode}            </OrbitComposerTriggerContext.Provider>          </Animated.View>        </Animated.View>      </GestureDetector>    </Animated.View>  );}export const OrbitComposer = Object.assign(OrbitComposerRoot, {  Action: OrbitComposerActionSlot,  Trigger: OrbitComposerTrigger,});
orbit-composer/uniwind/variants.ts
import { tv } from "tailwind-variants";export const orbitComposerSlots = {  action: "absolute",  actionContent:    "flex-1 items-center justify-center rounded-full bg-white shadow-[0_8px_14px_rgba(23,23,19,0.18)] active:scale-[0.96] active:opacity-80",  container: "relative",  trigger: "absolute z-2",  triggerContent:    "flex-1 items-center justify-center rounded-full bg-[#171713] shadow-[0_10px_16px_rgba(23,23,19,0.24)]",} as const;export type OrbitComposerSlot = keyof typeof orbitComposerSlots;export const orbitComposerVariants = tv({  slots: orbitComposerSlots,  variants: {    disabled: {      true: {        container: "opacity-[0.45]",      },    },  },});
orbit-composer/use-orbit-composer.ts
import {  useCallback,  useMemo,  useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import {  runOnJS,  type DerivedValue,  type SharedValue,  useAnimatedStyle,  useDerivedValue,  useReducedMotion,  useSharedValue,  withSpring,} from "react-native-reanimated";import { ORBIT_COMPOSER_CONFIG } from "./config";import type {  OrbitComposerAction,  OrbitComposerAnimation,  OrbitComposerAnchor,  OrbitComposerBaseProps,  OrbitComposerPlacement,  OrbitComposerPosition,} from "./types";type OrbitComposerLayout = {  height: number;  width: number;};type OrbitComposerMotion = "arc" | "orbit";type ResolvedOrbitComposerAnimation = Exclude<  OrbitComposerAnimation,  "adaptive">;type UseOrbitComposerOptions = Pick<  OrbitComposerBaseProps,  | "actionSize"  | "animation"  | "anchor"  | "defaultOpen"  | "disabled"  | "haptics"  | "onOpenChange"  | "onSelect"  | "open"  | "placement"  | "radius"  | "safePadding"  | "triggerSize"> & {  actions: readonly OrbitComposerAction[];};function clamp(value: number, minimum: number, maximum: number) {  "worklet";  return Math.min(maximum, Math.max(minimum, value));}function getDirectionalSpace(  anchor: OrbitComposerPosition,  layout: OrbitComposerLayout,  safePadding: number,) {  return {    bottom: layout.height - anchor.y - safePadding,    left: anchor.x - safePadding,    right: layout.width - anchor.x - safePadding,    top: anchor.y - safePadding,  };}function resolveGeometry(  actionSize: number,  anchor: OrbitComposerPosition,  layout: OrbitComposerLayout,  placement: OrbitComposerPlacement,  radius: number,  safePadding: number,): {  centerAngle: number;  fullCircle: boolean;  spread: number;} {  if (placement === "circle") {    return {      centerAngle: -90,      fullCircle: true,      spread: 360,    };  }  if (placement !== "auto") {    return {      centerAngle: getPlacementCenterAngle(placement),      fullCircle: false,      spread: ORBIT_COMPOSER_CONFIG.arcSpread,    };  }  const space = getDirectionalSpace(anchor, layout, safePadding);  const requiredSpace = radius + actionSize / 2;  if (    Math.min(space.top, space.right, space.bottom, space.left) >=    requiredSpace  ) {    return {      centerAngle: -90,      fullCircle: true,      spread: 360,    };  }  const nearBottom = space.bottom < requiredSpace;  const nearLeft = space.left < requiredSpace;  const nearRight = space.right < requiredSpace;  const nearTop = space.top < requiredSpace;  if (nearBottom && nearRight) {    return {      centerAngle: -135,      fullCircle: false,      spread: 90,    };  }  if (nearBottom && nearLeft) {    return {      centerAngle: -45,      fullCircle: false,      spread: 90,    };  }  if (nearTop && nearRight) {    return {      centerAngle: 135,      fullCircle: false,      spread: 90,    };  }  if (nearTop && nearLeft) {    return {      centerAngle: 45,      fullCircle: false,      spread: 90,    };  }  const directionalSpace = {    bottom: space.bottom,    left: space.left,    right: space.right,    top: space.top,  } as const;  const directions = ["top", "right", "bottom", "left"] as const;  const direction = directions.reduce(    (bestPlacement, nextPlacement) =>      directionalSpace[nextPlacement] >      directionalSpace[bestPlacement]        ? nextPlacement        : bestPlacement,    "top",  );  return {    centerAngle: getPlacementCenterAngle(direction),    fullCircle: false,    spread: ORBIT_COMPOSER_CONFIG.arcSpread,  };}function getPlacementCenterAngle(  placement: Exclude<OrbitComposerPlacement, "auto" | "circle">,) {  const angles = {    bottom: 90,    left: 180,    right: 0,    top: -90,  } as const;  return angles[placement];}function createActionGeometry({  actionCount,  actionSize,  anchor,  layout,  placement,  radius,  safePadding,}: {  actionCount: number;  actionSize: number;  anchor: OrbitComposerPosition;  layout: OrbitComposerLayout;  placement: OrbitComposerPlacement;  radius: number;  safePadding: number;}) {  const geometry = resolveGeometry(    actionSize,    anchor,    layout,    placement,    radius,    safePadding,  );  const angleStep =    actionCount <= 1      ? geometry.spread      : geometry.spread /        (geometry.fullCircle ? actionCount : actionCount - 1);  const minimumRadius =    actionCount <= 1      ? radius      : (actionSize + safePadding) /        (2 * Math.sin(((angleStep * Math.PI) / 180) / 2));  const resolvedRadius = Math.max(radius, minimumRadius);  const actionRadius = actionSize / 2;  const minimumX = safePadding + actionRadius;  const maximumX = layout.width - safePadding - actionRadius;  const minimumY = safePadding + actionRadius;  const maximumY = layout.height - safePadding - actionRadius;  const motion: OrbitComposerMotion = geometry.fullCircle    ? "orbit"    : "arc";  return {    motion,    positions: Array.from(      { length: actionCount },      (_value, index) => {        const angle = geometry.fullCircle          ? geometry.centerAngle +            (index / actionCount) * geometry.spread          : geometry.centerAngle -            geometry.spread / 2 +            (index / Math.max(1, actionCount - 1)) *              geometry.spread;        const radians = (angle * Math.PI) / 180;        return {          x: clamp(            anchor.x + Math.cos(radians) * resolvedRadius,            minimumX,            maximumX,          ),          y: clamp(            anchor.y + Math.sin(radians) * resolvedRadius,            minimumY,            maximumY,          ),        };      },    ),  };}function resolveAnchor(  anchor: OrbitComposerAnchor | undefined,  layout: OrbitComposerLayout,  safePadding: number,  triggerSize: number,): OrbitComposerPosition {  if (typeof anchor === "object") {    return anchor;  }  const edgeInset = safePadding + triggerSize / 2;  const horizontal = {    center: layout.width / 2,    left: edgeInset,    right: layout.width - edgeInset,  } as const;  const vertical = {    bottom: layout.height - edgeInset,    center: layout.height / 2,    top: edgeInset,  } as const;  const anchors = {    bottom: {      x: horizontal.center,      y: vertical.bottom,    },    "bottom-left": {      x: horizontal.left,      y: vertical.bottom,    },    "bottom-right": {      x: horizontal.right,      y: vertical.bottom,    },    center: {      x: horizontal.center,      y: vertical.center,    },    left: {      x: horizontal.left,      y: vertical.center,    },    right: {      x: horizontal.right,      y: vertical.center,    },    top: {      x: horizontal.center,      y: vertical.top,    },    "top-left": {      x: horizontal.left,      y: vertical.top,    },    "top-right": {      x: horizontal.right,      y: vertical.top,    },  } as const;  return anchors[anchor ?? "center"];}export function useOrbitComposer({  actionSize = ORBIT_COMPOSER_CONFIG.actionSize,  actions,  animation = "adaptive",  anchor: providedAnchor,  defaultOpen = false,  disabled = false,  haptics = true,  onOpenChange,  onSelect,  open,  placement = "auto",  radius = ORBIT_COMPOSER_CONFIG.radius,  safePadding = ORBIT_COMPOSER_CONFIG.safePadding,  triggerSize = ORBIT_COMPOSER_CONFIG.triggerSize,}: UseOrbitComposerOptions) {  if (actions.length > ORBIT_COMPOSER_CONFIG.maxActions) {    throw new Error(      `OrbitComposer supports up to ${ORBIT_COMPOSER_CONFIG.maxActions} actions.`,    );  }  if (    actionSize <= 0 ||    radius <= 0 ||    triggerSize <= 0 ||    safePadding < 0  ) {    throw new Error(      "OrbitComposer requires positive sizes and a non-negative safePadding.",    );  }  const [layout, setLayout] = useState<OrbitComposerLayout>({    height: 0,    width: 0,  });  const [internalOpen, setInternalOpen] = useState(defaultOpen);  const focusedIndex = useSharedValue(-1);  const reducedMotion = useReducedMotion();  const isOpen = open ?? internalOpen;  const anchor = resolveAnchor(    providedAnchor,    layout,    safePadding,    triggerSize,  );  const actionGeometry = useMemo(    () =>      createActionGeometry({        actionCount: actions.length,        actionSize,        anchor,        layout,        placement,        radius,        safePadding,      }),    [      actionSize,      actions.length,      anchor.x,      anchor.y,      layout,      placement,      radius,      safePadding,    ],  );  const { motion, positions } = actionGeometry;  const disabledActions = useMemo(    () => actions.map((action) => Boolean(action.disabled)),    [actions],  );  const progress = useDerivedValue(() =>    reducedMotion      ? Number(isOpen)      : withSpring(          Number(isOpen),          ORBIT_COMPOSER_CONFIG.spring,        ),  );  const setOpen = useCallback(    (nextOpen: boolean) => {      if (disabled) {        return;      }      if (open === undefined) {        setInternalOpen(nextOpen);      }      onOpenChange?.(nextOpen);    },    [disabled, onOpenChange, open],  );  const close = useCallback(() => {    setOpen(false);  }, [setOpen]);  const toggle = useCallback(() => {    setOpen(!isOpen);  }, [isOpen, setOpen]);  const select = useCallback(    (index: number) => {      const action = actions[index];      if (!action || action.disabled || disabled) {        return;      }      if (haptics) {        void Haptics.impactAsync(          Haptics.ImpactFeedbackStyle.Light,        );      }      onSelect?.(action.id);      setOpen(false);    },    [actions, disabled, haptics, onSelect, setOpen],  );  const notifyFocus = useCallback(() => {    if (haptics) {      void Haptics.selectionAsync();    }  }, [haptics]);  const onLayout = useCallback((event: LayoutChangeEvent) => {    const { height, width } = event.nativeEvent.layout;    setLayout((currentLayout) => {      if (        currentLayout.height === height &&        currentLayout.width === width      ) {        return currentLayout;      }      return { height, width };    });  }, []);  const gesture = useMemo(() => {    const pan = Gesture.Pan()      .enabled(!disabled && actions.length > 0)      .minDistance(8)      .onStart(() => {        runOnJS(setOpen)(true);      })      .onUpdate((event) => {        const pointerX = anchor.x + event.translationX;        const pointerY = anchor.y + event.translationY;        let nextFocusedIndex = -1;        let nearestDistance =          actionSize *          ORBIT_COMPOSER_CONFIG.focusDistanceMultiplier;        positions.forEach((position, index) => {          const distance = Math.hypot(            pointerX - position.x,            pointerY - position.y,          );          if (            distance < nearestDistance &&            !disabledActions[index]          ) {            nearestDistance = distance;            nextFocusedIndex = index;          }        });        if (nextFocusedIndex !== focusedIndex.get()) {          focusedIndex.set(nextFocusedIndex);          if (nextFocusedIndex >= 0) {            runOnJS(notifyFocus)();          }        }      })      .onEnd(() => {        const selectedIndex = focusedIndex.get();        focusedIndex.set(-1);        if (selectedIndex >= 0) {          runOnJS(select)(selectedIndex);          return;        }        runOnJS(close)();      })      .onFinalize(() => {        focusedIndex.set(-1);      });    const tap = Gesture.Tap()      .enabled(!disabled)      .onEnd((_event, success) => {        if (success) {          runOnJS(toggle)();        }      });    return Gesture.Exclusive(pan, tap);  }, [    actionSize,    anchor.x,    anchor.y,    close,    disabled,    disabledActions,    focusedIndex,    notifyFocus,    positions,    select,    setOpen,    toggle,  ]);  return {    anchor,    close,    focusedIndex,    gesture,    isOpen,    onLayout,    positions,    progress,    select,    toggle,    motion,    animation,  };}export function useOrbitActionAnimatedStyle({  animation,  anchor,  focusedIndex,  index,  motion,  position,  progress,}: {  animation: OrbitComposerAnimation;  anchor: OrbitComposerPosition;  focusedIndex: SharedValue<number>;  index: number;  motion: OrbitComposerMotion;  position: OrbitComposerPosition;  progress: DerivedValue<number>;}) {  let resolvedAnimation: ResolvedOrbitComposerAnimation =    animation === "adaptive" ? "bloom" : animation;  if (animation === "adaptive" && motion === "arc") {    resolvedAnimation = "fan";  }  let stagger = 0;  if (resolvedAnimation === "fan") {    stagger = index * 0.075;  }  if (resolvedAnimation === "cascade") {    stagger = index * 0.06;  }  if (resolvedAnimation === "spin") {    stagger = index * 0.035;  }  return useAnimatedStyle(() => {    const localProgress = clamp(      (progress.get() - stagger) / (1 - stagger),      0,      1,    );    const focused = focusedIndex.get() === index;    const deltaX = position.x - anchor.x;    const deltaY = position.y - anchor.y;    const distance = Math.max(      1,      Math.hypot(deltaX, deltaY),    );    const followsCurve =      resolvedAnimation === "fan" ||      resolvedAnimation === "spin";    const bend =      (resolvedAnimation === "spin" ? 28 : 18) +      index * 2;    const controlX =      deltaX * 0.45 + (-deltaY / distance) * bend;    const controlY =      deltaY * 0.45 + (deltaX / distance) * bend;    const translateX =      followsCurve        ? 2 *            (1 - localProgress) *            localProgress *            controlX +          localProgress ** 2 * deltaX        : deltaX * localProgress;    const translateY =      followsCurve        ? 2 *            (1 - localProgress) *            localProgress *            controlY +          localProgress ** 2 * deltaY        : deltaY * localProgress;    let initialScale = 0.48;    if (resolvedAnimation === "bloom") {      initialScale = 0.34;    }    if (resolvedAnimation === "cascade") {      initialScale = 0.58;    }    const restingScale =      initialScale + localProgress * (1 - initialScale);    let entryRotation = 0;    if (resolvedAnimation === "spin") {      entryRotation =        (index % 2 === 0 ? -32 : 32) *        (1 - localProgress);    }    if (resolvedAnimation === "bloom") {      entryRotation =        (index % 2 === 0 ? -18 : 18) *        (1 - localProgress);    }    if (resolvedAnimation === "fan") {      entryRotation = -12 * (1 - localProgress);    }    return {      opacity: localProgress,      transform: [        { translateX },        { translateY },        { rotate: `${entryRotation}deg` },        {          scale:            restingScale * (focused ? 1.12 : 1),        },      ],    };  });}

API reference

Generated directly from the exported component props.

OrbitComposer

OrbitComposer.Action

OrbitComposer.Trigger