Building Swipe Actions component with React and Framer Motion

Building Swipe Actions component with React and Framer Motion

Published at 19 August 2024

Swipe Actions is a UI component mostly used on iOS. When you swipe a list item to the left or right, context actions are revealed. For example, in a mail app, it might be "Mark as read" or "Delete".

In this tutorial, we'll learn how you can make such a component for the web, using React and Framer Motion. This component heavily relies on the useSnap hook, which I covered in the last post. Check it out to understand how this hook works and grab its source code at the end of the post.

Table of contents

What are we building

Our goal is to build a universal SwipeActions component that can be used as a foundation to build other components like these:

Cozy.fm – How to start journaling
Artboards_Diversity_Avatars_by_Netguru
Alice
16:25
About your car extended warranty
Try swiping the elements to the left or move focus to them using Tab

The components above use some newer CSS features and might look funky in older browsers. Sorry!

To make everyone's life easier, our component will come unstyled. We'll save some time by not writing styles, and whoever ends up using our component will be able to customize it to their needs. To make it even more flexible, we'll make it composable. So instead of a single <SwipeActions /> component, we'll have the following structure:

{/* This is a root component that ties everything together */}
<SwipeActions.Root>
    {/* Trigger is a component that the user will drag to reveal the actions */}
    <SwipeActions.Trigger></SwipeActions.Trigger>
    {/* Actions act as a wrapper and are mostly used to measure actions 
        size to create the correct snap points */}
    <SwipeActions.Actions>
        {/* Action is just a wrapper over the button. They aren't required, as 
            you can create "swipe actions", which will show some additional info too */}
        <SwipeActions.Action></SwipeActions.Action>
        <SwipeActions.Action></SwipeActions.Action>
        <SwipeActions.Action></SwipeActions.Action>
    </SwipeActions.Actions>
</SwipeActions.Root>

If you used Radix Primitives, you know the drill. Our component won't have as elaborate of API as Radix Primitives does (or else you'll be reading this until tomorrow), but the concept is the same.

Let's get to coding

Before we can start coding, we need to install some modules (besides React, which I assume you have installed already):

npm install framer-motion clsx react-merge-refs react-use-motion-measure

clsx and react-merge-refs are small utilities which allow us to combine class names and refs. react-use-motion-measure is a fork of react-use-measure which uses MotionValue's instead of state. This allows us to react to element size changes without re-rendering the whole component.

Create files SwipeActions.tsx and SwipeActions.css and import everything we'll need for this project.

// !!! Don't forget to import useSnap hook or paste it in the same file!
 
import { motion, MotionValue, useMotionValueEvent, useTransform } from 'framer-motion';
import { useSnap } from '../Snapping/useSnap';
import { ComponentProps, createContext, Dispatch, ReactNode, Ref, SetStateAction, useContext, useMemo, useRef, useState } from 'react';
import clsx from 'clsx';
import { mergeRefs } from "react-merge-refs";
import useMotionMeasure from 'react-use-motion-measure';
 
import './SwipeActions.css'

Root

Our component will consist of a few smaller components, and they all need to somehow share state. This is what React Context is for. Our Root component will wrap everything with a context provider, so all children will have access to the central state.

const SwipeActionsContext = createContext<{
    // We'll cover every property as we go
    actionsWrapperInset: number,
    actionsWidth: number,
    setActionsWidth: Dispatch<SetStateAction<number>>,
    triggerRef: Ref<HTMLDivElement>,
    triggerHeight: MotionValue<number>,
    dragProps: ReturnType<typeof useSnap>["dragProps"],
    setOpen: (open: boolean) => void,
}>(null as any);
 
const useSwipeActionsContext = () => {
    const ctx = useContext(SwipeActionsContext);
    if (!ctx) throw new Error('SwipeActionsContext.Provider is missing');
    return ctx;
};

Let's start with the Root component. It will act as a wrapper for other components and will be what ties them together. It will also be the owner of the shared state.

export type SwipeActionsRootProps = {
    className?: string,
    children?: ReactNode,
};
 
const Root = ({ className, children }: SwipeActionsRootProps) => {
    const [actionsWidth, setActionsWidth] = useState(0);
    const actionsWrapperInset = 2;
 
    const handleRef = useRef<HTMLDivElement>(null);
    const [triggerMeasureRef, triggerBounds] = useMotionMeasure();
 
    const constraints = useMemo(() => ({
        right: 0,
        left: -actionsWidth - actionsWrapperInset
    }), [actionsWidth, actionsWrapperInset]);
 
    const { dragProps, snapTo } = useSnap({
        direction: "x",
        ref: handleRef,
        snapPoints: {
            type: 'constraints-box',
            points: [{ x: 0 }, { x: 1 }],
            unit: 'percent',
        },
        constraints,
        dragElastic: { right: 0.04, left: 0.04 },
        springOptions: {
            bounce: 0.2,
        },
    });
 
    return (<SwipeActionsContext.Provider value={{
        actionsWidth,
        setActionsWidth,
        triggerRef: mergeRefs([handleRef, triggerMeasureRef]),
        dragProps,
        triggerHeight: triggerBounds.height,
        actionsWrapperInset,
        setOpen: (open) => snapTo(open ? 0 : 1),
    }}>
        <div className={clsx('SwipeActions', className)}>{children}</div>
    </SwipeActionsContext.Provider>);
};

Let's unpack. We start with the actionsWidth state which we need to calculate constraints. You see, we won't allow the user to drag the element uncontrolled, we'll constrain its movement so it just shows the actions underneath. There shouldn't be any movement to the right at all, and for the left side, we use the action's width to allow just the right amount of movement. actionsWrapperInset acts as a small padding to avoid any parts of actions peeking from underneath the trigger.

Next is the useSnap hook. We pass our constraints and snap points based on these constraints. Since we limit movement to the x axis, our snap points will only have the x property, which describes their position inside the constraints box. In our case, it's leftmost when actions are visible ({ x: 0 }) and rightmost when actions are hidden ({ x: 1 }).

And finally, we render children wrapped in div and SwipeActionsContext.Provider with the required state and all the functions.

While our component is unstyled, we still need to add a couple of styles to allow actions to hide under the trigger element. To implement such layout, we'll use the position CSS property. Specifically, we'll set position: relative for the root element, and position: absolute with inset for actions. This will allow actions to take all the available space remaining underneath main content without stretching the container.

.SwipeActions {
    position: relative;
}

Trigger

Trigger is a component that the user needs to drag to reveal the actions. It doesn't do anything besides passing triggerRef and dragProps from context to the element.

export type SwipeActionsTriggerProps = {
    className?: string,
    children?: ReactNode,
};
 
const Trigger = ({ className, children }: SwipeActionsTriggerProps) => {
    const { dragProps, triggerRef } = useSwipeActionsContext();
 
    return (<motion.div
        role="button"
        tabIndex={0}
        className={clsx('trigger', className)}
        ref={triggerRef}
        {...dragProps}
    >
        {children}
    </motion.div>);
};

To make sure that trigger always stays on top of actions, we also need to specify its z-index.

.SwipeActions .trigger {
    position: relative;
    z-index: 1;
}

Actions

And then we have actions. This component acts as a wrapper for action buttons and is used to correctly measure and set constraints for the trigger element.

export type SwipeActionsActionsProps = {
    className?: string,
    wrapperClassName?: string,
    children?: ReactNode,
};
 
const Actions = ({ className, children, wrapperClassName }: SwipeActionsActionsProps) => {
    const { actionsWrapperInset, setOpen, triggerHeight, setActionsWidth } = useSwipeActionsContext();
    const actionsWrapperHeight = useTransform(triggerHeight, v => v - actionsWrapperInset);
 
    const [actionsMeasureRef, actionsBounds] = useMotionMeasure();
    useMotionValueEvent(actionsBounds.width, 'change', setActionsWidth);
 
    return (<motion.div
        className={clsx("actions-wrapper", wrapperClassName)}
        style={{
            // Need to set height explicitly or otherwise Firefox and Safari will incorrectly calculate actions width
            height: actionsWrapperHeight,
            inset: actionsWrapperInset,
        }}
    >
        <motion.div
            className={clsx('actions', className)}
            ref={actionsMeasureRef}
            onFocus={() => setOpen(true)}
            onBlur={() => setOpen(false)}
        >
            {children}
        </motion.div>
    </motion.div>);
};

There are a couple of different things going on. Firstly, we explicitly set height of actions outer wrapper based on trigger height. This is related to differences between Firefox/Safari and Chrome. Those browsers render and measure elements with aspect-ratio differently in some cases when one of the sizes is not set explicitly. Unfortunately, our component would be one of such cases, so we have to set the height explicitly. aspect-ratio is required to make the buttons square like on example components, so we absolutely need it working across browsers.

Secondly, we measure the width of actions and update the state every time it changes. We use state here (instead of MotionValue) because our constraints and snap points depend on actions width, so when it changes, components need to be re-rendered.

Lastly, we handle focus. Usually, div elements can't receive focus, but since focus and blur events in React bubble (unlike their native counterparts), our onFocus and onBlur will be called when any of the children receive or lose focus. This will happen, among other things, when the user navigates with the keyboard and moves focus to one of the actions inside. In this case, we'd like to "open" swipe actions, so the user will see where their focus went. And the same when focus leaves, we close swipe actions to keep the UI tidy.

The styles for the action wrapper are a bit more elaborate than for previous components.

.SwipeActions .actions-wrapper {
    position: absolute;
    display: flex;
    justify-content: flex-end;
    overflow: hidden;
}
 
.SwipeActions .actions {
    height: 100%;
    display: flex;
    align-items: stretch;
}

The outer wrapper has absolute positioning (+ we set inset in JavaScript), which makes it take all available space of the parent (Root) element. And we use flex to move the child element to the right edge of the wrapper. The inner wrapper aligns children in a row and is used to measure their total size.

Action

This is only an optional component. You can use Root, Trigger and Actions to build a component that exposes additional info when swiped without any actual actions. And so you won't need the Action component.

But since it's not the main use case and most implementations will have actions inside, this component will come handy. It's just a <button> component with additional behavior on click. When this button is clicked, we call the original onClick handler and check if event.preventDefault() was called. If it wasn't, we'd close the swipe actions and unfocus activeElement (which is our button). It's important to not leave focus inside swipe actions when they are closed, as the browser will fire the focus event again when the user goes to another tab and then back, which will make swipe actions open and look weird.

But if event.preventDefault() was called, we'll assume the user doesn't want the default behavior and will take control over the component from here.

export type SwipeActionActionsProps = ComponentProps<typeof motion.button>;
 
const Action = ({ className, children, onClick, ...props }: SwipeActionActionsProps) => {
    const { setOpen } = useSwipeActionsContext();
    return (<motion.button
        className={clsx('action', className)}
        onClick={(e) => {
            onClick?.(e);
            if (!e.defaultPrevented) {
                setOpen(false);
                (document.activeElement as HTMLElement | null)?.blur();
            }
        }}
        {...props}
    >
        {children}
    </motion.button>);
};

Another important feature of <Action /> is changes to default focus styles. Since our action wrapper has overflow: hidden, the default focus ring will be partly hidden, which isn't user-friendly and looks choppy overall. To fix this, we change the outline offset to be negative, so the outline will be rendered inside the element.

.SwipeActions .action:focus-visible {
    outline-offset: -2px;
    outline-width: 2px;
}

Wrapping up

And with that, we're done. All that's left is to pack all the components into one object and export it.

export const SwipeActions = {
    Root,
    Trigger,
    Actions,
    Action,
};

Now you have a reusable Swipe Actions component that you can style however you want. If you want to extend it further, here are a couple of ideas how you can improve it:

  • Programmatically open swipe actions and/or control state from outside. For example, for desktop users, you might want to display a more conventional "three dots" menu button, which, on click, opens swipe actions. This will tie a familiar pattern to a new component, which eases its adoption.

  • Add support for swipe actions that swipe to the right. Or maybe even in both directions?

Here is the complete code of the component:

import './SwipeActions.css'
import { motion, MotionValue, useMotionValueEvent, useTransform } from 'framer-motion';
import { useSnap } from '../Snapping/useSnap';
import { ComponentProps, createContext, Dispatch, ReactNode, Ref, SetStateAction, useContext, useMemo, useRef, useState } from 'react';
import clsx from 'clsx';
import { mergeRefs } from "react-merge-refs";
import useMotionMeasure from 'react-use-motion-measure';
 
 
const SwipeActionsContext = createContext<{
    actionsWrapperInset: number,
    actionsWidth: number,
    setActionsWidth: Dispatch<SetStateAction<number>>,
    triggerRef: Ref<HTMLDivElement>,
    triggerHeight: MotionValue<number>,
    dragProps: ReturnType<typeof useSnap>["dragProps"],
    setOpen: (open: boolean) => void,
}>(null as any);
 
const useSwipeActionsContext = () => {
    const ctx = useContext(SwipeActionsContext);
    if (!ctx) throw new Error('SwipeActionsContext.Provider is missing');
    return ctx;
};
 
export type SwipeActionsRootProps = {
    className?: string,
    children?: ReactNode,
};
 
const Root = ({ className, children }: SwipeActionsRootProps) => {
    const [actionsWidth, setActionsWidth] = useState(0);
    const actionsWrapperInset = 2;
 
    const handleRef = useRef<HTMLDivElement>(null);
    const [triggerMeasureRef, triggerBounds] = useMotionMeasure();
 
    const constraints = useMemo(() => ({
        right: 0,
        left: -actionsWidth - actionsWrapperInset
    }), [actionsWidth, actionsWrapperInset]);
 
    const { dragProps, snapTo } = useSnap({
        direction: "x",
        ref: handleRef,
        snapPoints: {
            type: 'constraints-box',
            points: [{ x: 0 }, { x: 1 }],
            unit: 'percent',
        },
        constraints,
        dragElastic: { right: 0.04, left: 0.04 },
        springOptions: {
            bounce: 0.2,
        },
    });
 
    return (<SwipeActionsContext.Provider value={{
        actionsWidth,
        setActionsWidth,
        triggerRef: mergeRefs([handleRef, triggerMeasureRef]),
        dragProps,
        triggerHeight: triggerBounds.height,
        actionsWrapperInset,
        setOpen: (open) => snapTo(open ? 0 : 1),
    }}>
        <div className={clsx('SwipeActions', className)}>{children}</div>
    </SwipeActionsContext.Provider>);
};
 
export type SwipeActionsTriggerProps = {
    className?: string,
    children?: ReactNode,
};
 
const Trigger = ({ className, children }: SwipeActionsTriggerProps) => {
    const { dragProps, triggerRef } = useSwipeActionsContext();
 
    return (<motion.div
        role="button"
        tabIndex={0}
        className={clsx('trigger', className)}
        ref={triggerRef}
        {...dragProps}
    >
        {children}
    </motion.div>);
};
 
export type SwipeActionsActionsProps = {
    className?: string,
    wrapperClassName?: string,
    children?: ReactNode,
};
 
const Actions = ({ className, children, wrapperClassName }: SwipeActionsActionsProps) => {
    const { actionsWrapperInset, setOpen, triggerHeight, setActionsWidth } = useSwipeActionsContext();
    const actionsWrapperHeight = useTransform(triggerHeight, v => v - actionsWrapperInset);
 
    const [actionsMeasureRef, actionsBounds] = useMotionMeasure();
    useMotionValueEvent(actionsBounds.width, 'change', setActionsWidth);
 
    return (<motion.div
        className={clsx("actions-wrapper", wrapperClassName)}
        style={{
            // Need to set height explicitly or otherwise Firefox and Safari will incorrectly calculate actions width
            height: actionsWrapperHeight,
            inset: actionsWrapperInset,
        }}
    >
        <motion.div
            className={clsx('actions', className)}
            ref={actionsMeasureRef}
            onFocus={() => setOpen(true)}
            onBlur={() => setOpen(false)}
        >
            {children}
        </motion.div>
    </motion.div>);
};
 
export type SwipeActionActionsProps = ComponentProps<typeof motion.button>;
 
const Action = ({ className, children, onClick, ...props }: SwipeActionActionsProps) => {
    const { setOpen } = useSwipeActionsContext();
    return (<motion.button
        className={clsx('action', className)}
        onClick={(e) => {
            onClick?.(e);
            if (!e.defaultPrevented) {
                setOpen(false);
                (document.activeElement as HTMLElement | null)?.blur();
            }
        }}
        {...props}
    >
        {children}
    </motion.button>);
};
 
 
export const SwipeActions = {
    Root,
    Trigger,
    Actions,
    Action,
};
.SwipeActions {
    position: relative;
}
 
.SwipeActions .trigger {
    position: relative;
    z-index: 1;
}
 
.SwipeActions .actions-wrapper {
    position: absolute;
    display: flex;
    justify-content: flex-end;
    overflow: hidden;
}
 
.SwipeActions .actions {
    height: 100%;
    display: flex;
    align-items: stretch;
}
 
.SwipeActions .action:focus-visible {
    outline-offset: -2px;
    outline-width: 2px;
}

And here is an example of usage:

export const SwipeActionsDemoChat = () => {
    return (<SwipeActions.Root className='chat-demo'>
        <SwipeActions.Trigger className='demo-trigger'>
            <div className="avatar">
                <Avatar />
            </div>
            <div className="info">
                <div className="title">
                    <div className="name">Alice</div>
                    <div className="time">16:25</div>
                </div>
                <div className="message">
                    About your car extended warranty
                </div>
 
            </div>
        </SwipeActions.Trigger>
        <SwipeActions.Actions wrapperClassName='demo-actions-wrapper'>
            <SwipeActions.Action
                className='demo-action'
                style={{ ['--color' as any]: '#ffe8e8' }}
            >
                <HiTrash />
                <div>Delete</div>
            </SwipeActions.Action>
            <SwipeActions.Action
                className='demo-action'
                style={{ ['--color' as any]: '#e8fff3' }}
            >
                <HiStar />
                <div>Bookmark</div>
            </SwipeActions.Action>
            <SwipeActions.Action
                className='demo-action'
                style={{ ['--color' as any]: '#e6f0ff' }}
            >
                <HiEnvelopeOpen />
                <div>Read</div>
            </SwipeActions.Action>
        </SwipeActions.Actions>
    </SwipeActions.Root>);
};
 
const Avatar = (props: ComponentProps<"svg">) => (
    <svg
        xmlns="http://www.w3.org/2000/svg"
        id="Layer_1"
        fill="#000"
        data-name="Layer 1"
        viewBox="50 50 266.34 266.34"
        {...props}
    >
        <g id="SVGRepo_iconCarrier">
            <defs>
                <style>
                    {
                        ".cls-1{fill:#00214e}.cls-2{fill:#e18477}.cls-3{fill:#a76962}.cls-5{fill:none;stroke:#00214e;stroke-miterlimit:10}"
                    }
                </style>
            </defs>
            <title>{"Artboards_Diversity_Avatars_by_Netguru"}</title>
            <path
                d="M218.31 54.07c-12-4-24.85-7.28-37.54-5.85a115 115 0 0 0-55.55 22C93.33 94 85.13 137 71.81 172.53c-5 13.33-10 26.79-12.17 40.87-6.32 41.77 21.57 56.83 57.72 67.09 45.55 12.93 95.4 9.07 139.81-6.53 37.83-13.29 53-45.33 45-83.6-4-18.88-11.54-36.8-19.14-54.55-14.46-33.68-28.13-69.39-64.72-81.74Z"
                className="cls-1"
            />
            <path
                d="M307.6 280.15a184.58 184.58 0 0 1-226.48-1l48.66-22.81a47.68 47.68 0 0 0 4.35-2.34l1.12-.7c.4-.25.79-.51 1.18-.78A46.54 46.54 0 0 0 151.1 236c4-7.55 5.32-15.88 5.38-24.38 0-5.73-.31-11.44-.37-17.18q-.06-4.75-.1-9.51l2 1 5.2 2.69 30.29 5.15 31.12 5.3.94 32L226 247l11.47 4.67 9 3.64Z"
                className="cls-2"
            />
            <path
                d="M225.51 227.76c-2.72 1.68-5.29 2.47-7.54 2.23-14.79-1.59-43.64-13.18-61.8-34.63q0-1.58-.06-3.15-.06-4.76-.1-9.51l2 1 5.2 2.69 30.29 5.15 31.12 5.3Z"
                className="cls-3"
            />
            <path
                d="M307.6 280.15a184.58 184.58 0 0 1-226.48-1l48.66-22.81q2.25-1.06 4.35-2.33c23.68 17.41 56.64 28.74 85.06 15.95 8.06-3.62 15.33-10 18.29-18.31l9 3.64Z"
                style={{
                    fill: "#ff2609",
                }}
            />
            <circle cx={130.67} cy={152.96} r={17} className="cls-2" />
            <circle cx={137.67} cy={150.96} r={17} className="cls-3" />
            <path
                d="M217.67 217.09c-20.64 3.86-72-16.78-83.74-57.46a98.14 98.14 0 0 1-3.9-26.07c0-1.73 0-3.45.08-5.15 1.28-28.34 15.78-52 38.69-58.65 1.32-.38 2.65-.7 4-1a47.86 47.86 0 0 1 24 1.68 66.87 66.87 0 0 1 26.32 16.67 84.09 84.09 0 0 1 8.52 10 96.4 96.4 0 0 1 14.54 30.05c11.82 40.74-13.1 87.06-28.51 89.93Z"
                className="cls-2"
            />
            <path
                d="M213.32 128.89a58 58 0 0 0 2 11.49c1 3.31 2.52 5.36 4.52 8.22a17.7 17.7 0 0 1 1.79 3c2.65 5.94-.86 12.53-7.54 12.53H210M195.34 156.45a3.4 3.4 0 0 0 2.11 6.38M218.05 116.61a31.19 31.19 0 0 1 22.85-2.16M165.58 119.5a36.76 36.76 0 0 1 31.23-1"
                className="cls-5"
            />
            <path
                d="M210.21 64.69c-9.92-1.93-21.82-1.59-31.55-1.47-5.31.06-10.69.4-15.75 2-14 4.51-23.11 18-28.79 31.6-1.45 3.47-16.44 38-16.11 38.07 9.6 3.12 19.88-1.22 28.89-5.75 26.74-13.47 55.12-35.12 66.62-63.74-1.07-.24-2.18-.49-3.31-.71Z"
                className="cls-1"
            />
            <path
                d="M185 172.55a1.86 1.86 0 0 1 2.68-.5c2.08 1.46 5.88 4.56 11.28 5.64 7.36 1.46 13.75-1.48 15.27.41.86 1.07-.19 2.38-2.2 4.05a19.69 19.69 0 0 1-14.86 3.69c-7.08-1.33-12.4-9.53-12.4-12.43a1.72 1.72 0 0 1 .23-.86Z"
                style={{
                    fill: "#fff",
                }}
            />
            <path
                d="M179.41 130.89c6.1.05 6.1 9.37 0 9.42h-.27c-6.1-.05-6.1-9.37 0-9.42h.27ZM229.67 130.23c5.67 0 5.67 8.7 0 8.74h-.25c-5.67 0-5.67-8.7 0-8.74h.25Z"
                className="cls-1"
            />
        </g>
    </svg>
);
.chat-demo {
    width: 320px;
    font-size: 16px;
 
    --border-radius: 12px;
}
 
.chat-demo .demo-trigger {
    cursor: grab;
    border: none;
    font-family: inherit;
    font-size: inherit;
    text-align: inherit;
    line-height: inherit;
    width: 100%;
 
    padding: 12px;
    background: white;
    border-radius: var(--border-radius);
    box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
    display: flex;
    gap: 16px;
    align-items: center;
}
 
.chat-demo .demo-trigger .avatar {
    width: 60px;
    height: 60px;
    padding: 8px;
    background: #ffdfdf;
    border-radius: 99px;
    flex-shrink: 0;
}
 
.chat-demo .demo-trigger .info {
    align-self: stretch;
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    white-space: nowrap;
    overflow: hidden;
}
 
.chat-demo .demo-trigger .title {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
}
 
.chat-demo .demo-trigger .name {
    font-weight: 500;
}
 
.chat-demo .demo-trigger .time {
    align-self: flex-end;
    color: #aaa;
    font-size: 14px;
}
 
.chat-demo .demo-trigger .message {
    color: #999;
    font-size: 14px;
    margin-right: 36px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
 
 
.chat-demo .demo-actions-wrapper {
    border-radius: var(--border-radius);
    background: #ffe8e8;
}
 
.chat-demo .demo-action {
    height: 100%;
    aspect-ratio: 1 / 1;
    border: none;
    color: oklch(from var(--color) calc(l * 0.65) calc(c * 5) h);
    background: var(--color);
    font-size: 12px;
    font-weight: 500;
    font-family: inherit;
    cursor: pointer;
    display: flex;
    flex-direction: column;
    gap: 8px;
    justify-content: center;
    align-items: center;
    transition: background 0.15s ease-in-out;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
}
 
.chat-demo .demo-action svg {
    font-size: 24px;
}
 
.chat-demo .demo-action:hover,
.chat-demo .demo-action:active {
    background: oklch(from var(--color) calc(l * 0.982) c h);
}
 
.chat-demo .demo-action:last-child {
    border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
Published at 19 August 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.

You may also like: