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?
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?
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)
Alright, so I had some spare time to investigate this issue and I've managed to create something that works.
Idea:
PolymorphicButton
being a base component, that is only responsible for providing an element based on the asChild
propMotionPolymorphicButton
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';