5 mins

Nov 20, 2024

iOS Widget Stack in Framer Motion

Apple is great at user experience. Switching over to an iPhone for me personally was like getting an experience that I as a consumer always deserved.

Widgets are great. They save screen space and minimize user actions. In iOS, we have an option to stack one or more apps together inside one single widget. You can drag through the apps and select the one you want to open. Easy and quick!

Original Widget Stack in iOS 17

In the above video, I have stacked 5 widgets of different apps, inside a single widget. That's why the name Widgets Stack. This is really cool and powerful because it allows users to quickly select an app and save a lot more screen space.

Widget Stack on Web

Paris2024

Photos

Start dragging over the widget in order to scroll through the app. Notice how the next app comes into the frame when you have dragged a bit further.

It is also completely keyboard accessible. Focus on the widget stack via tab key, then press up or down arrow key in order to move through the apps.

Read the rest of the blog to see how to implement it.


Implementing in Framer Motion ✨

On a basic level, widget stack is a drag enabled vertical carousel. Most carousels, like this one, work by storing and updating an index value. This index value can correspond to images or components or anything you want to carousel.

Here, we wire up the index to different apps or components and then change the index so that the apps appear to be moving in and out of the stack. Let's implement the drag gesture and the animations, because the core logic is same as any other carousel.

The Drag-on!

In order to enable the drag in framer motion, all we need is to pass the drag prop and assign it an axis:

// container div
<div>
  <motion.div  {/* motion div that activates the drag.*/}
    drag="y" // drag axis set to Y or vertical.
  ></motion.div>
</div>

We will change the index state when the user has dragged a bit further than a pre-defined threshold. Also, we need to figure out whether the user has dragged from Top to Bottom or Bottom to Top. This is due the fact apps can come into view from either up or down.

Let's throw in some more useful props that allows us to do what we need:

<div>
  <AnimatePresence>
    <motion.div
      whileDrag={{ scale: 0.85 }} // while dragging, scale the children to 0.85
      drag="y" // drag axis
      onDrag={onDrag}
      onDragEnd={onDragEnd}
      dragConstraints={constraintsRef}
    >
      {/*WIP*/}
    </motion.div>
  </AnimatePresence>
</div>
onDrag

onDrag callback is called on every drag event. Since we need to calculate if user has crossed the threshold value or not, in order to update the index, we need to keep track of how far the user has dragged. We do that with onDrag function.

It receives an event and info arguments from FM. info has offset property which includes offsets for x and y direction. We only consider the y offset so we take it out and store in a motion value.

import { useMotionValue } from "framer-motion"
const dragY = useMotionValue(0)
function onDrag(event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) {
  dragY.set(info.offset.y)
}
onDragEnd

onDragEnd callback is called when a drag event ends. When the user stops and releases the drag, we need to check if the dragY has crossed the pre-defined threshold or not, if yes, we update the index.

function onDragEnd() {
  const amountDragged = dragY.get()
  if (amountDragged >= DRAY_Y_OFFSET) {
    // top to bottom direction
    /* set the index here */
  } else if (amountDragged <= -DRAY_Y_OFFSET) {
    // bottom to top direction
    /* set the index here */
  }
}

psst!! But how we do we figure out the direction?

We can do that by looking at the dragY motion value. Try dragging the below dummy stack and observe the Drag Y value:

Drag Y 0.00

Drag Y is positive for Top to Bottom and negative for Bottom to Top direction. For positive, we will set a direction state for as 1 and -1 for negative direction. We will expand the onDragEnd function:

const [direction, setDirection] = useState(1)
 
function onDragEnd() {
  const amountDragged = dragY.get()
  // if amount dragged is positive that means user dragged from top to bottom
  // so we set the direction as 1
  if (amountDragged > 0 && amountDragged >= DRAY_Y_OFFSET) {
    setDirection(1)
    /* set the index here */
  } else if (amountDragged < 0 && amountDragged <= -DRAY_Y_OFFSET) {
    // if amount dragged is negative that means user dragged from bottom to top
    // so we set the direction as -1
    setDirection(-1)
    /* set the index here */
  }
}

You can follow any approach to set the index.

dragConstraints

In order to limit the drag movement, we can put constraints on the motion.div. The div will not go beyond the constraints. We can put constraints over the drag via BoundingBox values or a ref object of some parent element. We chose ref object of the parent element because it is convenient.

The Animations

We animate the initial, target and exit states. We will use variants in order to have more control over the animations, and we will use the index and direction values accordingly.

Some Gotchas: State values like index or direction which get frequently updated in the component and are used in the animations, tend to become stale while using in animations. Quoting Framer Motion docs:

When a component is removed, there's no longer a chance to update its props. So if a component's exit prop is defined as a dynamic variant and you want to pass a new custom prop, you can do so via AnimatePresence. This will ensure all leaving components animate using the latest data.

So we will provide the custom prop to both AnimatePresence and motion.div.

<div>
  <AnimatePresence custom={direction}>
    <motion.div
      whileDrag={{ scale: 0.85 }} // while dragging, scale the children to 0.85
      drag="y" // drag axis
      onDrag={onDrag}
      onDragEnd={onDragEnd}
      dragConstraints={constraintsRef}
      custom={direction}
    >
      {/*WIP*/}
    </motion.div>
  </AnimatePresence>
</div>
variants

Variants are sets of pre-defined animation targets. We can pre-define the animations in an object structure. With this approach we can pass the animation values as functions, and framer motion will supply the custom prop value to those function as a param:

const variants = {
  initial: (direction: number) => {
    return { y: `${-100 * direction}%`, scale: 0.4, opacity: 0.7 }
  },
  target: { y: "0%", scale: 1, opacity: 1 },
  exit: (direction: number) => {
    return {
      y: `${100 * direction}%`,
      scale: 0.4,
      opacity: 0.7,
      transition: { duration: 0.26, type: "tween", ease: "easeOut" },
    }
  },
}

initial and exit animations are functions with direction param because we need to use the direction value otherwise apps will come into the view from one side only.

Now, the apps will come into view from (-100 * direction)% and will exit from (100 * direction)% depending upon the direction.

The three indicator dots are really simple. If the current index matches the index of any dot, we just fill it with a color (white/black).

<AnimatePresence initial={false}>
  {showIndicator && (
    <motion.div
      key="motion_indicator"
      initial={{ opacity: 0, x: "-10%" }}
      animate={{ opacity: 1, x: "0%" }}
      exit={{ opacity: 0, x: "-10%" }}
      transition={{ duration: 0.6, type: "spring", bounce: 0.3 }}
    >
      {compArray.map((_, currentIndex) => {
        return (
          <span
            key={currentIndex}
            className={`w-2 h-2 rounded-full border border-white m-1 ${
              currentIndex === index && "bg-white"
            }`}
          ></span>
        )
      })}
    </motion.div>
  )}
</AnimatePresence>

When the drag starts, we mount the indicator dots and unmount them few seconds later. This is done via onDragStart prop and its handler:

function onDragStart() {
  setShowIndicator(true)
  const id = setTimeout(() => {
    setShowIndicator(false)
  }, 2550)
  clearInterval(timeoutIdRef.current)
  timeoutIdRef.current = id
}
<AnimatePresence initial={false}>
  {showIndicator && (
    <motion.div
      {/* rest of the props */}
    >
      {/* rest of the props */}
    </motion.div>
  )}
</AnimatePresence>

We can show app names via the data array like this:

<p className="w-full text-center text-white text-base">
  {compArray[index].name}
</p>

Hurray. You made it this far. We are already friends (~ ̄ ▽  ̄)~.

That was Widget Stack. Thanks to Framer Motion for a good drag API. Framer Motion really helped in quickly making a prototype for the widget stack.

Anyways, Thanks for reading. See you in the next blog.