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
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 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.