MCPInstall

design-to-motion

Turns a static design (a Figma frame, dashboard, card set, app screen, or a UI on a photo background) into a pixel exact HTML replica that loads in from an empty state, then has every visual act out what it does (a chart being read, a list receiving items, a prompt being typed) in a seamless loop, and exports an MP4 for X, a website GIF, and an 800×500 GIF. Use this skill whenever the user asks to animate a design, a Figma link, a screenshot, UI cards, a dashboard, or a hero image, to make a loading or appear animation, a showcase loop, a motion mockup, or a video or GIF of a design, even when they only say "animate this". Also use it on landing pages and websites for bento and feature cards that pair text with a visual, and for product or dashboard mockups. Always consult it before exporting layers, choosing what moves, building the intro, writing the visual story loops, or encoding video and GIFs.

Installs
190
Last 24h
+88
Trending rank
#2163
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/elayadesign/ai-design-skills --skill design-to-motion
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads design-to-motion automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

Design to Motion

A showcase animation is not a redesign. The viewer must see the exact design, and must see that it is alive.

exact frame → empty state → smooth load → every visual acts out its story → seamless repeat.

This skill has three parts. Part A rebuilds the design so the finished frame is indistinguishable from the source. Part B is the motion system. Part C records and exports. Work through A before touching B, and never encode anything before the checks in A7 and B9 pass. Parts A and B apply in both contexts below; Part C is for the showcase, or for a website only when exports are asked for.

Scope

Apply to any request to animate a static design: Figma frames, UI cards, dashboards, app screens, marketing heroes, product mockups, and designs placed on a photo background. When a rule here conflicts with a framework default, this file wins. When the user's explicit prompt conflicts with a rule, the user wins.

Not for interaction motion inside a shipping product (hovers, menus, toasts). For that, use a product motion skill such as emilkowalski/skills. Companion skill: landing-page-design in this repo, when the animation will live on a landing page.

Two contexts

Decide the context before anything else. It changes what you animate, how the motion starts and ends, and what you deliver.

ShowcaseWebsite
Typical request"Animate this design for me", a Figma link and nothing elseBuilding or editing a landing page or site, or the request names one
What gets animatedThe whole frameOnly the sections that qualify (table below)
StartPage load, from the empty stateWhen the section scrolls into view, once
After the introStories run, then the frame dissolves and the cycle repeats (B5)Stories run forever. No dissolve, no cycle
TextExported layers, so the frame is pixel exactLive HTML text in the site's font, for SEO and accessibility. Only the visual parts are exported images or SVG
DeliverablesThe page plus MP4 for X, website GIF, 800×500 GIF (Part C)The animated sections inside the site's code. Exports only when asked

If the request does not say which, look at where you are working: inside a site's codebase, assume website. With only a design and no site, assume showcase. If it is still unclear, ask once.

Website: which sections get which motion

Scan the page section by section. Apply this skill only where a section has a visual part, plus the hero (below). Never animate any other text section with it.

SectionApply
Hero (always), with or without a visual: the header, headline, subtext, buttons, and proof lineThe hero appear (below) on page load. Any visual inside the hero then follows its own row in this table
Bento and feature sections: cards with text plus a visual (a chart, list, flow, calendar, prompt, player)Card intro on scroll (B2, card kind, text included), then a story loop on each card's visual (B4). This is the main use on a landing page
Product or dashboard mockup (a hero shot of the app, a product screenshot)The loading intro on scroll: surfaces stay, contents load (A5). Then ask the user whether they also want stories after loading. Do not add them unasked
Logo rows, integrationsA marquee or a gentle light up story
Text only sections: headline blocks, testimonials, FAQ, pricing, CTA, footerNot this skill. Use the page's normal scroll reveal (for example landing-page-design B7)

The hero appear. Every website gets it, every time, even when the hero is only text.

  • The page opens plain. The first frame is the page background alone (and the static joined surfaces of an app frame mockup, if the hero has one). No text, no header, no buttons yet.
  • A really small animation. Each element rises just 6px and resolves from a 2px blur to sharp, with an opacity fade. Nothing slides far, nothing scales, nothing bounces. It should feel like the page settling into place, not like a show.
  • Fast and in reading order. Header first, then the badge, the headline line by line, the subtext, the buttons, and the proof line, 50 to 70ms apart. The whole text appear lands within about 1.2s.
  • On page load, once. It plays when the page first loads, not on scroll, and never replays when the visitor scrolls back up.
  • Visuals in the hero (floating cards, a product mockup) start their own intro after the headline, following their row in the table.

Website rules:

  • Trigger with IntersectionObserver. Below the hero, start each section's intro when about 30% of it is visible. Play it once. Never use a scroll listener.
  • Pause stories off screen. Stop the clock when the section leaves the viewport and resume it on return.
  • Stagger by reading order inside the section, never across the whole page.
  • Respect the site's system. Use its components, tokens, radii, and fonts. Fidelity to the design still applies (A7), compared section by section.
  • prefers-reduced-motion: reduce shows each section in its final state with no stories.

PART A — Fidelity

A1. Intake

Gather these before building. Ask only for what is missing, and ask in one batch.

  • Context. Showcase or website (see "Two contexts"). Decide this first.
  • Source. A Figma link with a node id is best. A screenshot or flat image works, but every layer has to be rebuilt from the pixels (see "Screenshot or flat image" in A2).
  • Layout. Decide it yourself from the tree (A5): an app frame (a dashboard or app screen built from joined surfaces, such as a sidebar and a body) or separate cards (a bento grid, a card set, widgets). The two load differently.
  • Background. Is there a photo or texture behind the UI? Assume it must stay exactly as designed.
  • Visuals. Which parts are visuals that should act out a story after load (B4)? Assume every chart, list, stack, calendar, flow, prompt, photo, media control, and status indicator.
  • Deliverables. Showcase: default to all three, MP4 for X, website GIF, 800×500 GIF. Website: the animated sections in the site's code, with exports only when asked.
  • Camera. Assume no zoom and no pan (B7).

If the user cannot answer, make a reasonable assumption, state it in one line, and continue. On the first design of a session, show the live page before recording exports, so the direction can be corrected cheaply.

A2. Source of truth

Figma. Use the REST API with a personal access token.

  • Read the token from a secure store (an environment variable or the OS keychain). Never print it, echo it, paste it into chat, or commit it.
  • GET /v1/files/:key/nodes?ids=:node&geometry=paths gives the tree, bounds, fills, effects, and radii.
  • GET /v1/images/:key?ids=:node&format=png&scale=2 renders the reference frame. The finished still must match this image (A7).
  • Note the frame's absolute origin. Every layer position is absolute bounds − origin.
  • The image URLs the API returns point at a CDN that sometimes resets connections. Retry every download, and check each PNG's size before using it.

Screenshot or flat image

A screenshot has no layers, no exact values, and nothing behind what you can see. It still works, but every element has to be rebuilt or lifted out of the pixels. A3's export table is Figma only; this section replaces it. A4 to A7 and all of Part B apply as usual.

Ask once, then continue. In the intake batch, ask for a Figma link if one exists (it gives a better result), the highest resolution export they have (2x or 3x, PNG, uncropped, not a JPEG), and, if the design sits on a photo, the original background image. If they have none of these, continue with what you have.

Set the stage. Find the pixel ratio before measuring anything. Divide the image width by common frame widths (1440, 1280, 1920, 390) and pick the one that gives a whole ratio, then check it against text: UI body text is usually 14 to 18px. The stage is the image size divided by that ratio, in CSS pixels. The screenshot is the reference for A7, resized to 2x of the stage if it is a different scale.

Measure in screenshot pixels. Pixel i covers i to i + 1, so its centre is i + 0.5; mixing the two puts round shapes half a pixel off. At a 2x ratio many positions land on half CSS pixels. Keep them. If the still then shows elements one device pixel off, Chrome is snapping them: lay the stage out in screenshot pixels and scale it by 1 / ratio, and scale every motion distance by the same ratio.

Choose how each element is made, in this order of preference. Use the first one that fits:

ElementHow
Surfaces, cards, pills, buttons, inputs, dividers, bars, skeleton lines, progress tracksRebuild in CSS. Sample each fill as the median of a flat patch, never a single pixel. Measure radii from the corner curve and borders from the edge profile. Gradients: sample a row of pixels along the gradient every 10 to 20px and use each sample as a stop, so a multi stop or uneven fade comes out right; when it also changes across the other axis, layer a second gradient. Shadows: render candidates and compare the profile of pixels across the edge with the reference, searching blur, offset, and opacity until the error stops dropping
Text, websiteLive HTML in the site's font, as always
Text, showcaseIdentify the font: ask, or match it by eye against common UI faces (Inter, SF Pro, Geist, Manrope, DM Sans, Satoshi, Plus Jakarta Sans). Retype it in HTML and overlay it on the reference. Overlay the HTML on the reference at 2x. The match is close when each line's width is within 1% and every baseline within one screenshot pixel; PSNR cannot judge text, since even the right font scores low on it. Keep a close match, since it stays sharp and can type or count. Otherwise lift the text out of the pixels (next row)
Text, icons, and glyphs on a flat or gradient surfaceLift them out as a transparent layer by un-blending against the surface colour you rebuilt. For single colour content: alpha = (bg − px) / (bg − fg) per pixel, using the channel with the largest contrast, then store fg with that alpha. Its edges then sit on your CSS surface without a seam. In practice: render your rebuilt surfaces first and un-blend against that render, not a guessed colour. Estimate fg once per text group (a heading, a paragraph, a link), not per box. Keep each box a few pixels clear of container edges and other elements, or they merge into it. Where pixels hold more than one colour (a two tone icon, colour fringes), fall back to colour to alpha against the surface
Simple iconsRedraw as SVG when the set is recognisable (Lucide, Phosphor, Heroicons, Material): it stays sharp at any scale. Otherwise lift them out as above
Lines and connectorsTrace the centerline from the pixels (the middle of the stroke in each column or row) and draw them as SVG strokes. They can then draw on (B4) without the mask trick
ChartsBars as CSS with measured heights. Lines and areas as SVG paths traced per column. They can then animate
Photos, avatars, logos, illustrationsCrop from the screenshot, masked to their container's radius. These are the only rectangular crops
Text, badges, or icons on top of a photoThey stay inside the photo crop and move only with it. List them as not separable

What is hidden stays hidden. Anything under another element (the part of a card behind an overlapping card, a list behind a dropdown, the background behind a panel) does not exist in the image. Never invent it silently.

  • Flat or gradient backgrounds rebuild in CSS, so the empty state is exact.
  • Photo backgrounds need the original image for any layout where cards rise in (A5), because the empty state shows what is behind them. Without it, tell the user before building and offer two choices: the parts behind the cards are filled in (inpainted, so they are guessed), or the layout is treated like an app frame, where the surfaces stay and only their contents load. Never fill it in without saying so.
  • Overlaps between elements. Choose motion that never reveals a hidden area. If the story needs one (a card sliding off another), rebuild the lower element fully in CSS, so nothing has to be guessed.
  • Hidden parts that shape something visible (a line that passes under a card, a tint whose edge runs underneath it): continue the simplest geometry that joins the visible ends (a straight run, or the same arc radius), and list it as guessed.

Resolution. A lifted layer or crop is only as sharp as the screenshot. Record exports at a scale no higher than the screenshot's own ratio wherever cropped pixels make up a large part of the frame, or rebuild more in CSS and SVG. When the screenshot is 1x, say up front that the result will be softer than a Figma build.

Story limits. Lifted text can type and delete (B4), but it cannot count: counters need live text in a matching font. Without one, choose another story for that visual.

Tell the user before animating, in a short list: what was rebuilt in CSS or SVG, what was lifted or cropped, what could not be separated, and what was guessed (the font, the pixel ratio, any hidden area). Never fake fidelity by animating the screenshot as one block, or by moving crops that carry their old background with them.

A3. Layer export

Figma sources only. From a screenshot, use the table in "Screenshot or flat image" (A2) instead.

Export every element that will move as its own PNG at 3x through /v1/images, batched 20 to 25 ids per call.

CasePlacement
PNG size equals absoluteRenderBounds (text layers always do)Place at the render bounds
PNG larger than the render bounds (a parent clipped it), effects that overhang evenlyx = bbox.x − (pngW − bbox.w) / 2. Use renderBounds.y when the top was not clipped, otherwise derive it from the effect radius and offset
Overhang that is not even (a line with an arrowhead, a stroke with one square cap)Take the bounds of strokeGeometry or fillGeometry from the geometry=paths response instead of the formula
Rotated vectors with a large layer blur (soft colour blobs)Export with use_absolute_bounds=true and place at the bounding box. Run a PSNR offset search against the reference only if that region scores below about 45 dB. Blobs can be stored at 1x; the loss is invisible
Zero height or zero width stroked vectorsuse_absolute_bounds=true returns null for them. Export without it
Mask group with blurred contentAnchor on the edge that was not clipped (usually the bottom of the mask plus its stroke)
Arcs, donuts, partial ellipsesExport with use_absolute_bounds=true
Patterns or overlays far larger than their surfaceExport with use_absolute_bounds=true, then crop to the surface rectangle
Instance children (ids like I12:34;56:78)They often return null. Export the parent instance
Lines that should draw on, dashed or notFigma outlines strokes into filled shapes, so there is no centerline to animate. Write the centerline yourself and use it as a mask over the exported PNG (example below)
A photo baked inside a larger exported layerExport the photo node on its own as well. Cover the baked photo with a skeleton, and put the separate photo layer on top for the photo resolve. At rest it must equal the reference

Rules that keep the frame exact:

  • Text stays an exported layer in a showcase. Never retype it in HTML there: fonts, kerning, and line metrics will not match. On a website, text is live HTML (see "Two contexts"). From a screenshot, there is no exported layer; follow the text rows in A2.
  • Keep Figma's z order. Static layers can sit between animated ones in the same order the tree has them.
  • Frame strokes usually draw above children. Render a card's stroke as a separate overlay above its clipped content, then confirm against the reference: a glow inside the card can tint the stroke in Figma's render.
  • Containers whose children move on their own. The API always exports a frame together with its children, so exporting a card or panel bakes in everything inside it. When its children must load or act separately, rebuild the container in CSS from the node data: fills, corner radii, inside strokes (including individualStrokeWeights), and effects. Then export each child separately.
  • Shadow mapping. A Figma drop shadow with blur R is a CSS box-shadow with blur R on a single element, or filter: drop-shadow() with blur about R / 2 on a group. Both give the same Gaussian. Verify against the reference.
  • A group shadow is the shadow of the union. When a drop shadow sits on a group of joined surfaces (an app frame), put all surface shapes in one static container with filter: drop-shadow(). Per element box-shadow would darken neighbours where surfaces overlap. Separate cards each carry their own shadow inside their card unit, so the shadow moves with the card.
  • Skip hidden layers (visible: false) and stray layers buried under opaque shapes.

Drawing a line on. Read the corner points and arc radii from the outline in strokeGeometry: the centerline sits halfway across the stroke width, and a rounded corner's centerline radius is the outline's inner radius plus half the stroke. Most UI connectors are straight runs and arcs, so the path is short:

<svg class="ly" viewBox="0 0 1440 990" style="left:0;top:0;width:1440px;height:990px">
  <mask id="draw1">
    <path d="M193 832 V700 A45 45 0 0 1 238 655 H380" fill="none" stroke="#fff" stroke-width="8"
          pathLength="1" stroke-dasharray="1" stroke-dashoffset="1"/>
  </mask>
  <image href="data:image/png;base64,..." x="192" y="653" width="190" height="181" mask="url(#draw1)"/>
</svg>

Animate stroke-dashoffset from 1 to 0 on the JS clock. pathLength="1" means no length measuring, and the wide mask stroke covers the line's antialiasing. For pulses that travel along it, read positions with getPointAtLength on the same path.

A4. Background (sacred)

When the design sits on a photo or texture, the background is part of the design, not decoration. Often the whole canvas outside the app frame is background.

  • It never changes. Not in the intro, not in the loop, not in the dissolve, not in any zoom. It never moves, blurs, scales, fades, or recolours.
  • Every version keeps it. A "loading only" or "no zoom" variant is still the same design on the same background. Never drop the photo to make a variant simpler.
  • Export the original image, not a render. GET /v1/files/:key/images returns the raw image fills. Apply the fill's scale mode and imageTransform as CSS: for a stretch fill with a transform, background-size is the frame size divided by the transform scale, and background-position is the negative offset.
  • Verify it. Compare the background region of your still against the reference. Aim above 45 dB PSNR. A crop or offset error shows up instantly.
  • Frosted panels must see the photo. A surface with backdrop-filter: blur() is its own element, a sibling above the background, never inside an ancestor that has filter or opacity below 1. Headless Chrome only renders it with transform: translateZ(0) and a z-index on that element.
  • If you cannot reproduce it, say so before building. No image access, a generated texture, a missing fill: tell the user and ask. Never swap in a flat colour silently.
  • Store photos as high quality JPEG or WebP, not PNG.

A5. Layout: app frame or separate cards

Decide which of the two layouts the design is. It changes what the empty state contains and what moves.

App frame (joined surfaces). A dashboard or app screen whose frame is built from two or more surfaces joined into one unit: a sidebar and a body, often overlapping by a few pixels. Only this layout gets static surfaces.

  • The surfaces exist in the empty state. The sidebar and body are in place, fully opaque, and still from the first frame. They never fade, slide, scale, or blur in. Only the elements inside them load.
  • Keep Figma's stacking where they meet. The body sits above the sidebar's content clip, so sidebar content can never draw over the body, and the body never slides over the sidebar.
  • Cards inside the body are content: they load like any other element.

Separate cards (bento, card sets, widgets). Cards that stand apart from each other with gaps between them. These are not joined surfaces.

  • The cards themselves load in. Each card rises in with blur (the card kind in B2), staggered in reading order, and its contents follow inside it.
  • The empty state is the background alone.

Both layouts:

  • Each container clips its own contents to its exact shape, including per corner radii. Nothing may overflow onto a neighbour or the background.
  • Image shaped surfaces. When a container's silhouette is a photo (a cover header, a full bleed card image), give it a placeholder in the image's average colour. The photo then loads into it.
  • Nested content is content. Trays, toolbars, and inner panels load like any other element.

Clip pattern, which keeps stage coordinates valid inside the clip. For separate cards, the card's unit wraps its clip, so the card and its contents move together:

<div class="clip" style="left:113px;top:138px;width:305px;height:907px;border-radius:16px 0 0 16px">
  <div class="cin" style="left:-113px;top:-138px"><!-- units in stage coordinates --></div>
</div>
.clip { position: absolute; overflow: hidden; isolation: isolate; }
.cin  { position: absolute; width: var(--stage-w); height: var(--stage-h); }

A6. Page architecture (showcase)

On a website, build inside the site's code instead, following the website rules in "Two contexts". The clock, units, and modes below still apply per section.

  • One self contained HTML file: a fixed size stage at the frame's exact size, scaled to fit the viewport. Images inline as base64, deduplicated when the same image repeats. Wait for decode on <img> elements only; CSS gradients are not images.
  • Modes: ?still renders the exact Figma frame with no motion (also used for reduced motion). ?loop repeats the cycle like the exports. ?capture turns off requestAnimationFrame and exposes window.__start(), window.__tick(dt), and the loop time window.__lt, so the recorder drives the clock (C1).
  • Units. Every animated element is wrapped in a full stage layer .u carrying its delay --d. The intro is CSS transitions keyed on .stage.in. Because a unit covers the whole stage, any percentage in clip-path or transform-origin on it refers to the stage, not the element. Use pixel values computed from the element's rectangle.
  • Static layers stay composited. Give clips and stroke overlays transform: translateZ(0), so their antialiasing does not change when nearby transitions start. Otherwise a 1px edge flickers at every cycle start.
  • One JS clock (requestAnimationFrame) drives everything time based: masks, sweeps, stories, accents. Drive it from loop time, never from wall clock, so capture is deterministic. In ?capture the clock advances only through __tick.
  • Default page behaviour: wait for image decode, play the intro once (when the frame scrolls into view, if it sits on a longer page), then keep the stories running.

A7. Verify the still

Render ?still at 2x and compare it with the reference before any motion work.

  • PSNR for the whole frame and for each container, after a light blur (gblur sigma≈1.2) so sub pixel antialiasing does not dominate. Aim above 35 dB overall and above 30 dB per container. Text heavy containers sit near 30 dB because 3x layers are resampled to 2x; if one is lower, retest it at sigma≈2.5, where text edges stop counting and only structure remains.
  • Look at a blurred, amplified difference image. It reveals structural errors: a glow placed too high, wrong stacking, a missing stroke, a shifted shadow.
  • Screenshot sources: also score the surfaces alone. Lifted layers carry the surface colour inside their boxes, so they hide errors in the rebuilt surface beneath them and inflate the score. Mask out the lifted and cropped regions and score the rest; it must pass the same thresholds.
  • Do not chase sub pixel offsets. Measuring at 2x cannot resolve errors under half a pixel, and "corrections" overshoot. Fix structure, not antialiasing.

PART B — Motion system

Every timing, distance, and curve below comes from the values in Make it yours. Never invent ad hoc values.

B1. Start empty

The first frame of every page load and every export is the empty state. Empty becomes loaded. Never open on the finished frame.

  • App frame: background plus the static joined surfaces, nothing else.
  • Separate cards: the background alone.

B2. Loading choreography

The appear animation is smooth, slow, and soft: elements rise from below and resolve from blur to sharp, staggered.

KindUse forFrom
cardSeparate cards (bento, card sets) as a whole, with their shadowtranslateY(24px), scale(0.985), blur(6px), opacity 0
upText, headers, values, rowstranslateY(10px), blur(4px), opacity 0
downTop toolbars, top barstranslateY(-8px), blur(4px)
leftSidebar items, list rows, labelstranslateX(-10px), blur(4px)
popIcons, badges, avatars, buttons, dotsscale(0.82–0.88) from the element's own center, blur(4px)
photoImagesskeleton shimmer first, then blur(14px) saturate(0.6) to sharp. For a photo baked into a bigger layer, see A3
wipeBars, fills, highlightsA left to right clip-path reveal in pixels on the full stage unit (A6). The right inset starts at stageW − x (zero width, so no sliver shows) and ends at stageW − (x + w) (the element fully shown). Top and bottom insets stay at y and stageH − (y + h)
line / growRules, ticks, timelinesscaleX(0) / scaleY(0) from their start edge
donutRings, gaugesconic mask sweeping clockwise from 12 o'clock
tray / riseSheets, trays, foldersslide up 40px from inside the container's bottom edge, clipped by it
fadeLayers that sit under another layeropacity only
  • Order. Containers load in reading order, 80 to 90ms apart: for an app frame, the regions inside its static surfaces; for separate cards, the cards themselves, each starting about 0.15s before its own contents. Inside a container: header, then labels, then values, then visuals. Items in a group stagger 30 to 80ms.
  • Never scale(0) on an object. Only lines and bars grow from zero, because they draw.
  • Covered layers arrive last and leave first. Anything hidden under another layer in the final frame (calendar weeks under a meeting tray) appears after its cover lands and exits faster in the dissolve, so it never ghosts through.
  • Skeletons match the real shape and sit 1px inside it, so no halo shows around the loaded image.
  • Total intro: about 2 to 2.5 seconds for a full frame.

B3. Bars fill like progress

  • Data bars, meters, and highlights fill like progress bars: a left to right clip-path wipe with the in out curve, 0.7 to 1.1s.
  • Never add loading lines. No progress line along a card's or surface's top edge, and no other bar or rule that is not in the design. Only shapes that exist in the design animate.

B4. Story loops (the main act)

The intro is only the opening. The main act is every visual acting out what it does, on a loop: a lead list receives new leads, a chart is being read, a calendar finds an open slot, a prompt gets typed. When a user says "animate the visuals", this is what they mean. A frame that only loads in and then sits there with a few pulses is not finished.

Write the story first. For each visual, name its story in one sentence before building, for example "a new lead lands on top and the older ones settle back". Pick from this table, or invent one in the same spirit.

VisualStory
Stack of items (leads, notifications, files)A new item lands on top with a blur, the others step back one slot, the last one leaves
Line or area chartA scrubber glides across, dots ride the curves, the tooltip value counts. Or: the line redraws, a marker pops, the numbers count up with it
Bar chart, equalizer, grid of cellsBars breathe like a live feed and settle back to the design heights. A dot and tooltip ride the active bar
Donut, gaugeSegments ease outward one at a time and back. Or: the ring sweeps again clockwise
Table, listA cursor inspects rows one by one, the row highlights, and a preview loads for it. Or: a soft highlight steps down the rows
Calendar, schedulerA highlight searches for an open slot, the event glides into it, then back
Flow, connectors, pipelinePulses travel along the connectors in the flow direction, and nodes or avatars light up as a pulse passes. Dashed lines march
AI, prompt, formThe prompt deletes and retypes, chips evaluate one by one, then the result appears with a blur
Counters, KPIsNumbers count up together with the chart that drives them
Profile, long contentThe content scrolls slowly inside its clip, then returns
Logo rowA continuous marquee that moves exactly one full set per loop, so it repeats seamlessly
Media playerThe playback handle and progress advance while the play button pulses

Rules for stories

  • Start and end on the exact design. Each story leaves the Figma frame, acts, and comes back to it. At rest, the frame equals ?still.
  • Use the design's own elements. Cards, rows, bars, dots, chips, and tooltips from the design. Helpers the story needs (a cursor, duplicate stack items, extra pulses) must match the design's style, and are hidden in ?still.
  • Changing text only when it is the point. Counters, typing, and tooltip values. All other text stays still.
    • Typing reveals the exported text layer glyph by glyph, never as a smooth wipe. Find the glyph boundaries from the empty alpha columns of the text PNG. Touching pairs (tt, /w, fi) leave no empty column, so compare the step count with the string's character count and split each wide run at its faintest column until they match. Then step the reveal one glyph every 45 to 70ms, and show a caret at the edge that blinks once a second while idle. Deleting runs the same steps backwards, a little faster.
    • Counters need live text in the design's font at the exact size and weight, landing on the design's exact text at rest.
  • Obvious, not busy. Every card with a story shows a clear action every one to two seconds. A story is made of beats (a pulse leaves, a node lights, the arrow fires), and its beats provide that rhythm. If a viewer cannot tell the frame is animated within two seconds, the stories are too timid ("make more movement"). Calm screens, such as a document dashboard, may rest after load if the user prefers. Ask once.
  • Text only cards get no story in a showcase. They rise in with their card and rest. Not every card needs to move; every visual does.
  • Readable at the smallest export. A story must still be visible in the 800×500 GIF. If a moving element is smaller than about 4px there (a pulse dot, a thin line), enlarge it within the design's style, add a halo, or pick a bigger story.
  • Offset the cards. Cards never act in unison. Shift each card's story so attention travels around the frame in reading order.
  • One story per card, three to eight seconds long. A short story may repeat within one cycle (twice in the 8.6s story phase is common), and a second run can vary a beat. Every run still ends at rest before the dissolve.
  • Stories are built from eased keyframe tracks on the JS clock (A6), so capture stays deterministic:
// [[time, value], ...] eased per segment; values may be numbers or arrays
function track(keys, t, ease = easeInOut) {
  if (t <= keys[0][0]) return keys[0][1];
  for (let i = 1; i < keys.length; i++) {
    const [t1, v1] = keys[i], [t0, v0] = keys[i - 1];
    if (t <= t1) {
      const k = ease(Math.min(1, (t - t0) / (t1 - t0 || 1)));
      return Array.isArray(v0) ? v0.map((v, j) => v + (v1[j] - v) * k) : v0 + (v1 - v0) * k;
    }
  }
  return keys[keys.length - 1][1];
}
// a slot that scrubs along a curve and returns to its design position
slot.style.transform = `translate(${track([[0, [0, 0]], [1.2, [46, -18]], [2.6, [46, -18]], [3.8, [0, 0]]], t).join('px,')}px)`;

Accents sit on top of stories, never instead of them:

AccentUse forValues
Pulse ringStatus dots, live indicators, play buttons1.8–2.2s period, ring scale 1 → 2.6, opacity 0.55 → 0
HeartbeatHearts, likes, sparkles+20% over 0.35s, every 3s
Gentle bobFloating objects (folders, devices, cards inside cards)±2.6px, 2.8s sine
Sheen sweepPhotos, bars, buttonsA band 38% of the element's width, peaking at 34% white, 1.5s pass every 3.4s. On light grey shapes it disappears; use a refill story instead
Skeleton glimmerPlaceholder lines inside the design2.4s shimmer
  • Stories and accents start when the intro has landed (about 2.4s). Stories begin from the exact design, so they need no fade. Accents and added helpers fade in over 0.6s.

B5. The loop cycle (showcase)

On a website there is no cycle: the intro plays once on scroll and the stories run until the section leaves the viewport.

PhaseDefault timing
Build in0 → 2.4s
Stories2.4 → 11.0s
Dissolve11.0 → 11.6s (opacity 0.5s)
Empty beat11.6 → 12.0s
  • The story phase holds at least one full story of every card. Lengthen the cycle rather than cutting a story short. Time the stories so every card is back at rest when the dissolve starts.
  • The live page keeps telling stories forever after its intro. Only ?loop and the exports dissolve and restart.
  • Dissolve back to the empty state (B1). For an app frame, only the contents dissolve and the joined surfaces stay. For separate cards, the cards dissolve too. The background never dissolves.
  • Reset everything. A class that disables transitions for one frame resets the CSS, but every JS driven value (masks, tracks, helpers, typed text, pulses) must be set back to its empty state too, or it ghosts through the empty beat. The frame at the end of a cycle must equal the frame at its start.

B6. Easing and duration

Never use default ease or linear for entrances.

TokenCurveUse
--ecubic-bezier(0.16, 1, 0.3, 1)Transforms on entrance (1.05s)
--e2cubic-bezier(0.33, 1, 0.68, 1)Opacity and blur (0.7–0.8s)
--iocubic-bezier(0.65, 0, 0.35, 1)Wipes, fills, sweeps, camera moves
linearlinearConstant motion only (shimmer, playback)

Showcase timing is slower than product UI timing on purpose. Product rules like "under 300ms" do not apply here; slow and smooth is the brief.

B7. Camera

  • No zoom and no pan by default. The user has to ask for it.
  • When asked: only the app frame scales toward the items, the background never moves (A4), moves use --io, and the frame returns to its resting position before the dissolve.

B8. Performance and accessibility

  • Animate transform, opacity, filter, and clip-path only. Never top, left, width, or height.
  • Keep animated blur at 14px or less. Static backdrop-filter is fine.
  • prefers-reduced-motion: reduce renders the still frame.
  • CSS transitions for the intro, the JS clock only for time based effects.

B9. Motion checks

Run these on the recorded frames (C1) before encoding.

  • Overflow check. Test with the containers' real shapes, rounded corners included, not rectangles: render a mask from the page itself (every container as a solid white shape with its exact radii on black, plus its shadow area if it has one), grow it by 2px to skip antialiasing, and compare only the black pixels. Any ?still used for comparison here is rendered at the capture's device scale factor, not at the 2x of A7.
    • App frame: every pixel outside the joined surfaces must be identical to the empty first frame in every frame of the cycle (PSNR inf).
    • Separate cards: during the story phase, every pixel outside the cards (and their shadows, when they have any) must be identical to the ?still frame, so no story, helper, or content escapes its card. The intro frames are excluded, because the cards' own rise moves pixels there; the contact sheet must show contents moving with their card, never ahead of it or outside it.
  • Seam check. The first frame of the export window equals the frame one cycle later.
  • Empty beat check. Every frame of the empty beat equals the frame before __start. The seam check cannot catch JS state that failed to reset, because both its frames are at loop time 0; this one can.
  • Rest check. The last frame before the dissolve equals the landed frame, the one at the end of the intro just before the first story (PSNR inf). That proves every story came back to the design. Then compare it with ?still at the capture scale: it should be very close (above about 38 dB) but may not be identical, because Chrome rasterizes an element that has run a transition slightly differently. A structural difference in that comparison is a bug.
  • Contact sheet. Tile frames at key moments (empty, mid intro, landed, several points through the stories, mid dissolve, end) and look for overflow, ghosting, pops, and stories too timid to notice.

PART C — Recording and export

C1. Deterministic capture

Never screen record in real time. Step a fake clock and screenshot every frame. Three details decide whether the loop is smooth and the seam is clean; all three are in the snippet.

await page.clock.install({ time: 0 });
await page.goto(url + '?capture', { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
await page.clock.pauseAt(10000);
const loopTime = [];
for (let i = 0; i < frames; i++) {
  // 1. whole milliseconds that average exactly 1000/60 (17, 17, 16, ...); runFor(1000 / 60) drifts
  const dt = Math.round((i + 1) * 1000 / 60) - Math.round(i * 1000 / 60);
  await page.clock.runFor(dt);
  loopTime.push(await page.evaluate(([i, PRE, dt]) => {
    if (i === PRE) window.__start();          // 2. start inside the same evaluate as the seek
    if (i >= PRE) window.__tick(dt / 1000);   // 3. the page clock advances here, never through rAF
    const now = performance.now();
    for (const a of document.getAnimations()) {  // CSS transitions follow the fake clock
      a.__m ??= window.__mark;                    // each one keeps the mark it was created under
      a.pause(); a.currentTime = Math.max(0, now - a.__m);
    }
    return i >= PRE ? window.__lt : 0;
  }, [i, PRE, dt]));
  await page.screenshot({ path: `${out}/${String(i).padStart(5, '0')}.png` });
}

On the page side, in ?capture:

  • __start() adds the in class. Every change of the stage's state classes (in, out, the reset) sets window.__mark = performance.now(), so each transition is seeked from the moment it was created. Transition delays (--d) are counted inside currentTime.
  • __tick(dt) advances the loop time, runs the cycle phases (B5), draws every story and accent, and sets window.__lt.
  • Loops that never end (shimmer, marquee) are driven by the JS clock, not by infinite CSS keyframes, which the seek would restart.

PRE is a few frames (about 15) recorded before the start, so the page has settled. Save loopTime beside the frames.

  • Viewport is the frame size. deviceScaleFactor = min(1920 / W, 1200 / H) so the long side hits the X limit. Screenshots round to whole pixels and can come out one pixel odd (1745×1200 for a 1440×990 frame); the MP4 crop to even dimensions takes care of it, so expect 1744×1200.
  • The export window starts at frame PRE − 1 (loop time 0, the empty state) and spans exactly cycle × 60 frames. Record at least 10 frames past it for the seam check.

C2. Formats

FileSpec
<name>-x.mp4H.264, yuv420p, 60fps, CRF 14–15, preset slow, +faststart, max 1920×1200, even dimensions
<name>-loop.gif960px wide, 15–20fps, about 2 to 3MB, for the website
<name>-800x500.gifAlways delivered. When the frame has wide empty borders (most card and bento frames), crop to the content at exactly 1.6:1, with about 40 CSS px of margin on the tighter axis; the other axis grows to reach the ratio. When the content fills the frame, fit it and pad with the page background colour. On a photo background, always crop, so no flat band sits beside the photo. 20fps, 12fps if heavy
ffmpeg -start_number $A -framerate 60 -i frames/%05d.png -frames:v $N \
  -vf "crop=trunc(iw/2)*2:trunc(ih/2)*2" -c:v libx264 -preset slow -crf 15 \
  -pix_fmt yuv420p -movflags +faststart name-x.mp4

ffmpeg -start_number $A -framerate 60 -i frames/%05d.png -filter_complex \
  "trim=end_frame=$N,fps=20,scale=960:-2:flags=lanczos,split[a][b];\
   [a]palettegen=max_colors=192:stats_mode=diff[p];\
   [b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" -loop 0 name-loop.gif

For the 800×500 GIF, add scale=800:500:force_original_aspect_ratio=decrease,pad=800:500:(ow-iw)/2:(oh-ih)/2:color=<page bg> before split.

  • Keep bayer dithering whenever a photo is in frame; without it photos band.
  • If a GIF is too heavy, lower the frame rate or colours before lowering the size.

Output format

Before building, state in a few lines:

  1. Context — showcase or website. For a website, list each section and what it gets (intro and stories, intro only, or nothing)
  2. Layout — app frame (which joined surfaces stay static) or separate cards (which cards rise in), and the clip shapes
  3. Background — kept exactly, or what cannot be reproduced
  4. Choreography — load order and the kind per element group
  5. Stories — one sentence per visual saying what it acts out, plus any accents
  6. Cycle — total length and phases

After building, report:

  • Still frame fidelity (overall and worst surface PSNR)
  • Overflow check and seam check results
  • The three files with size, dimensions, frame rate, and duration
  • Anything not reproduced exactly, named plainly

Make it yours

Every motion and export value lives here. Edit this section and the rest of the skill follows.

ValueDefault
Rise distance10px (up), 8px (down), 10px (left)
Entrance blur4px, photos 14px
Pop start scale0.82–0.88
Transform duration1.05s with --e
Opacity and blur duration0.7–0.8s with --e2
Wipe duration0.7s, slow bars 1.1s, with --io
Card rise24px, scale(0.985), blur(6px), 0.9s transform with --e, 0.6s opacity
Hero appear (website)6px rise, 2px blur, 0.8s transform with --e, 0.6s opacity, 50–70ms stagger, done within about 1.2s
Container stagger80–90ms
Tray and folder rise40px
Typingone glyph every 45–70ms, caret blink 1s
Item stagger30–80ms
Story start2.4s
Story length3–8s per card, offset between cards
Cycle12s (dissolve at 11.0s, reset at 11.6s), longer if a story needs it
MP460fps, CRF 15, max 1920×1200
Website GIF960px wide, 20fps
Small GIF800×500, 20fps

Quick checklist

Fidelity

  • Reference frame rendered from the source
  • Every moving element exported as its own layer, text included
  • Screenshot source: pixel ratio found, each element rebuilt, lifted, or cropped (A2), hidden areas never invented silently, the list shown to the user before animating
  • Figma z order kept, strokes above children, group shadow on the union
  • Background is the original image, positioned by its transform, verified by PSNR
  • Every variant keeps the background, frosted panels blur the photo behind them
  • Still frame matches the reference (no structural differences)

Context and layout

  • Context decided: showcase, or website with each section mapped (text only sections untouched)
  • Website hero: page opens plain, then the small hero appear plays once on load
  • Website mockups: asked the user before adding stories after the loading intro
  • Layout identified: app frame with joined surfaces, or separate cards
  • App frame: joined surfaces present, opaque, and still in the empty state, Figma stacking kept where they meet
  • Separate cards: cards rise in themselves, empty state is the background alone
  • Each container clips its own contents with its exact radii
  • Overflow check passes for the layout type

Motion

  • Exports and page load start on the empty state
  • Rise plus blur to sharp intro, staggered in reading order
  • Bars fill like progress bars, no added loading lines or shapes that are not in the design
  • Every visual acts out a story after load, starting and ending on the exact design; text only cards rest
  • Stories stay visible in the 800×500 GIF
  • Stories are obvious, offset between cards, and only counters or typing change text
  • Covered layers arrive last and leave first
  • No zoom or pan unless asked, background never moves
  • Dissolves back to the empty state, background untouched
  • Seam, empty beat, and rest checks pass

Export

  • Deterministic frame capture: whole millisecond steps averaging 1000/60, start and tick inside the seek, no real time recording
  • <name>-x.mp4, <name>-loop.gif, <name>-800x500.gif delivered
  • Token never printed or committed

Credits

Part of ai-design-skills by Elaya, MIT licensed.

A few general motion rules were adapted, not copied, from these MIT licensed skill collections:

FAQ

What does the design-to-motion skill do?

Turns a static design (a Figma frame, dashboard, card set, app screen, or a UI on a photo background) into a pixel exact HTML replica that loads in from an empty state, then has every visual act out what it does (a chart being read, a list receiving items, a prompt being typed) in a seamless loop, and exports an MP4 for X, a website GIF, and an 800×500 GIF. Use this skill whenever the user asks to animate a design, a Figma link, a screenshot, UI cards, a dashboard, or a hero image, to make a loading or appear animation, a showcase loop, a motion mockup, or a video or GIF of a design, even when they only say "animate this". Also use it on landing pages and websites for bento and feature cards that pair text with a visual, and for product or dashboard mockups. Always consult it before exporting layers, choosing what moves, building the intro, writing the visual story loops, or encoding video and GIFs.

How do I install design-to-motion?

Run npx skills add https://github.com/elayadesign/ai-design-skills --skill design-to-motion in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/design-to-motion/ (personal) or .claude/skills/design-to-motion/ (project).

Which AI agents can use design-to-motion?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is design-to-motion safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains design-to-motion?

It is published by elayadesign in elayadesign/ai-design-skills. It has 190 recorded installs.

More from elayadesign/ai-design-skills

View all →

landing-page-design

elayadesign/ai-design-skills

Complete system for building high converting landing pages: intake questions, page structure, layout selection, conversion copywriting, SEO, plus strict visual rules for typography, spacing, corner radius, backgrounds, hero layout, icons, and motion. Use this skill whenever building, editing, styling, reviewing, or writing copy for ANY landing page, marketing site, web UI, page section, component, or prototype, even when the user does not mention design, conversion, tokens, or a design system. Always consult it before choosing page sections, headlines, CTAs, fonts, font sizes, spacing values, border radii, background colors, icons, or transitions.

Frontend & Design1.9K installs

Popular frontend & design skills

View category →

setup-matt-pocock-skills

mattpocock/skills

Configure this repo for the engineering skills: set up its issue tracker, triage label vocabulary, and domain doc layout. Run once before first use of the other engineering skills.

Frontend & Design1.1M installs

frontend-design

anthropics/skills

Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.

OfficialFrontend & Design1M installs

hyperframes-animation

heygen-com/hyperframes

All animation knowledge for HyperFrames — atomic motion rules, multi-phase scene blueprints, scene transitions, broader motion-design techniques, AND the seven runtime adapters (GSAP default, plus Lottie, Three.js, Anime.js, CSS keyframes, Web Animations API, TypeGPU). Use for any motion or animation task: pick 2-4 rules and compose, or load a blueprint, or look up runtime-specific API (e.g. GSAP eases / Lottie player / Three.js mixer). Also covers auditing an existing composition's choreography (animation map) and 24 named text-animation effects. HyperFrames-native: single paused timeline, seek-safe, deterministic.

prototype

mattpocock/skills

Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.

Frontend & Design999.2K installs

hyperframes-core

heygen-com/hyperframes

The HyperFrames composition contract — build one renderable project. Use for composition structure, the data- timing attributes, class="clip", tracks, sub-compositions, variables, framework-owned media playback, deterministic-render rules, and validation. Read before writing composition HTML.

Frontend & Design930.5K installs

vercel-react-best-practices

vercel-labs/agent-skills

React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.

OfficialFrontend & Design843.1K installs