Search code examples
reactjstypescriptframer-motionradix-ui

How to pre-configure a basic framer-motion animation in a polymorphic component that uses Radix Slot?


Based on the Slot documentation, when your component has a single children element, a polymorphic button can be created accordingly:

// your-button.jsx
import React from 'react';
import { Slot } from '@radix-ui/react-slot';

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button';
  return <Comp {...props} />;
}

What if you want to pre-configure the Button component to provide some basic animation using framer-motion?

Problem

A polymorphic button component should return a motion component that scales to 0.9 while tapping it.

With framer-motion, this can be achieved using Motion Component accordingly:

function Button({ ..props }) {
  return <motion.button whileTap={{ scale: 0.9 }} {...props} />;
}

How do we achieve the same behavior with the polymorphic button that uses Slot?

What I've tried:

I tried to wrap Slot with the motion higher-order-component, but it throws a type conflict.

My Button component:

export interface ButtonProps extends HTMLMotionProps<'div'> {
  asChild?: boolean;
}

const MotionSlot = motion(Slot);

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, ...props }, ref) => {
    const Comp = asChild ? MotionSlot : motion.button;

    return <Comp whileTap={{ scale: 0.9 }} ref={ref} {...props} />;
  }
);

Button.displayName = 'Button';

Typescript error:

Type '{ className?: string | undefined; title?: string | undefined; defaultChecked?: boolean | undefined; defaultValue?: string | number | readonly string[] | undefined; suppressContentEditableWarning?: boolean | undefined; ... 314 more ...; ref: ForwardedRef<...>; }' is not assignable to type 'Omit<SlotProps & RefAttributes<HTMLElement> & MotionProps, "ref">'.
  Types of property 'style' are incompatible.
    Type 'MotionStyle | undefined' is not assignable to type '(CSSProperties & MakeCustomValueType<{ outline?: MotionValue<number> | MotionValue<string> | MotionValue<any> | Outline<string | number> | undefined; ... 820 more ...; vectorEffect?: MotionValue<...> | ... 3 more ... | undefined; }> & MakeCustomValueType<...> & MakeCustomValueType<...> & MakeCustomValueType<...>) | ...'.
      Type 'MotionStyle' is not assignable to type '(CSSProperties & MakeCustomValueType<{ outline?: MotionValue<number> | MotionValue<string> | MotionValue<any> | Outline<string | number> | undefined; ... 820 more ...; vectorEffect?: MotionValue<...> | ... 3 more ... | undefined; }> & MakeCustomValueType<...> & MakeCustomValueType<...> & MakeCustomValueType<...>) | ...'.
        Type 'MotionStyle' is not assignable to type 'CSSProperties & MakeCustomValueType<{ outline?: MotionValue<number> | MotionValue<string> | MotionValue<any> | Outline<string | number> | undefined; ... 820 more ...; vectorEffect?: MotionValue<...> | ... 3 more ... | undefined; }> & MakeCustomValueType<...> & MakeCustomValueType<...> & MakeCustomValueType<...>'.
          Type 'MotionStyle' is not assignable to type 'CSSProperties'.
            Types of property 'accentColor' are incompatible.
              Type 'MotionValue<number> | MotionValue<string> | CustomValueType | MotionValue<any> | AccentColor | undefined' is not assignable to type 'AccentColor | undefined'.
                Type 'MotionValue<number>' is not assignable to type 'AccentColor | undefined'.ts(2322)

Solution

  • Alright, so I had some spare time to investigate this issue and I've managed to create something that works.

    Idea:

    1. Create 3 components:
      • PolymorphicButton being a base component, that is only responsible for providing an element based on the asChild prop
      • MotionPolymorphicButton being a result of wrapping PolymorphicButton with framer-motion's motion Higher-Order-Component (HoC). This allows us to define animations on the PolymorphicButton.
      • Button acting as a HoC, that injects animation properties into MotionPolymorphicButton

    Code:

    interface PolymorphicButtonProps extends ComponentPropsWithoutRef<'button'> {
      asChild?: boolean;
    }
    
    // Forwarding the ref is mandatory for using the `motion` function,
    // ensuring proper animation handling.
    const PolymorphicButton = forwardRef<HTMLButtonElement, PolymorphicButtonProps>(
      ({ asChild = false, ...rest }, ref) => {
        const Comp = asChild ? Slot : 'button';
    
        return (
          <Comp
            ref={ref}
            {...rest}
          />
        );
      }
    );
    
    PolymorphicButton.displayName = 'PolymorphicButton';
    
    /* -----------------------------------------------------------------------------------------------*/
    
    // Wrapping PolymorphicButton with `motion` avoids complex type handling,
    // keeping the button polymorphic and animated.
    const MotionPolymorphicButton = motion(PolymorphicButton);
    
    /* -----------------------------------------------------------------------------------------------*/
    
    // Define pre-configured motion props for the Button component
    const buttonMotionProps = {
      whileTap: { scale: 0.9 },
    } as const satisfies HTMLMotionProps<'button'>;
    
    const isTargetAndTransition = (
      field: VariantLabels | TargetAndTransition
    ): field is TargetAndTransition =>
      typeof field !== 'string' && !Array.isArray(field);
    
    interface ButtonProps
      extends ComponentPropsWithoutRef<typeof MotionPolymorphicButton> {
      disableDefaultAnimations?: boolean;
    }
    
    const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
      // Merges pre-configured whileTap behavior with user props
      const whileTap = useMemo<
        VariantLabels | TargetAndTransition | undefined
      >(() => {
        if (props.disableDefaultAnimations) {
          return props.whileTap;
        }
        if (props.whileTap === undefined) {
          return buttonMotionProps.whileTap;
        }
        return isTargetAndTransition(props.whileTap)
          ? { ...buttonMotionProps.whileTap, ...props.whileTap }
          : props.whileTap;
      }, [props.disableDefaultAnimations, props.whileTap]);
    
      return (
        <MotionPolymorphicButton
          {...buttonMotionProps}
          {...props}
          ref={ref}
          whileTap={whileTap}
        />
      );
    });
    
    Button.displayName = 'Button';