emilkowalski

Activity

Stars and mentions · Last 30 days, Oct 6 – Nov 4

GitHub stars7,246
Mentions69
0156312468624Oct 6Oct 20Nov 4
UTC days · repositories emilkowalski owns3 repositories

Top repositories

emilkowalski’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside emilkowalski’s account where emilkowalski opened or reviewed pull requests in the past 365 days.

Who noticed emilkowalski

Everyone who starred or forked emilkowalski’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

66posts
Nov 51 post
  1. A new skill: /choreograph Helps you coordinate several elements that animate together. It adds stagger when needed, animates based on importance, ensures correct timing for each element type, and more. emilkowalski/skills

    705 likes · 21 reposts · 16,179 views
  2. Does your AI-built UI survive a long name or an empty list? Emil Kowalski's break-ui catalog has ready test cases for missing avatars, narrow layouts and more. Free, MIT-licensed: github.com/emilkowalski/skills/blob/main/skills/b… #WebDev

  3. Run this skill workflow over the weekend on your app: /remove-animations → /improve-animations → /animation-performance It removes unnecessary, annoying animations, improves existing ones, and makes sure they are performant, free wins. emilkowalski/skills

    781 likes · 21 reposts · 19,140 views
  4. Sonner Alternative for React: 4 Toast Libraries Compared TL;DR The right Sonner alternative depends on what Sonner does not do for you, and if nothing is wrong, keep it: it is by far the most downloaded of the four toast libraries compared here and has a stack, swipe and promise toasts built in. Pick react-hot-toast for the smallest bundle (4.8 kB gzip) and a headless hook, or React-Toastify for progress bars, a queue limit and a notification history. Pick a source-owned toast, such as the free Wingo UI Toast, when you need to change how the stack behaves on phones, for screen readers or in your design system. You go looking for a Sonner alternative when something about the toast stops fitting: the bundle, a design system that fights its stylesheet, an accessibility review, or action buttons that are hard to hit on a phone. Sonner is still the default React toast for good reasons, so leaving it should fix a specific problem. This comparison puts Sonner next to react-hot-toast, React-Toastify and a source-owned toast on the API, the stack, swipe, screen readers, phones and bundle size, checked in October 2026 against each package's published code and docs. We build Wingo UI, whose free Toast is the source-owned option here, so read this as a vendor's comparison with sources; it says where each of the others is the better pick. More head to head comparisons live under Comparisons and alternatives, and the guide to shadcn alternatives compares whole component libraries. What is the best Sonner alternative for React? The best Sonner alternative depends on what you are leaving for: - A smaller bundle and your own markup: react-hot-toast. It measured 4.8 kB gzip in our build, half of Sonner, and its useToaster hook lets you render every toast yourself. - More features built in: React-Toastify. Progress bars, a queue limit, toast.update, a stacked mode, RTL and a notification history add-on, with no extra packages. - Behavior you can change: a toast whose source lives in your repository, such as the free Wingo UI Toast. It keeps a Sonner-style toast() API, and the stack, the timers and the phone layout are code you can edit. - No problem to fix: stay on Sonner. It is the most downloaded React toast notification library of the four by a wide margin, and shadcn/ui installs it for Radix and React Aria projects. How do Sonner, react-hot-toast and React-Toastify compare? The table reads the latest version of each package on npm as of October 9, 2026. Sizes are our own measurement: each library's toaster component and toast function bundled with esbuild 0.28, minified, React left external, then gzipped. Sonner and React-Toastify inject their CSS from JavaScript, so their numbers include the styles. Downloads are npm's count for October 1 to 7, 2026. | | Sonner | react-hot-toast | React-Toastify | Wingo UI Toast | | --- | --- | --- | --- | --- | | Version | 2.0.8, Aug 9, 2026 | 2.6.1, Sep 16, 2026 | 11.1.0, Apr 19, 2026 | Toast 1.0.0 in registry 1.7.0, Oct 8, 2026 | | License | MIT | MIT | MIT | Free item, Wingo UI license | | Weekly npm downloads | 50.8 million | 3.7 million | 2.9 million | Not on npm, copied by the CLI | | Size, min + gzip | 9.6 kB | 4.8 kB | 9.8 kB | 12.2 kB, plus motion if you lack it | | Default position and time | bottom-right, 4 s | top-center, 2 s for success, 4 s for others | top-right, 5 s | bottom-right, 4 s | | Stack | Collapsed, fans out on hover, 3 visible | A list | A list, or collapsed with stacked | Collapsed, fans out on hover or tap, 3 visible | | Swipe to dismiss | 45px or a flick | None | Touch only, 80% of the width | Touch and mouse, a quarter of the toast or a flick | | Keyboard | Alt+T focuses the list, Esc collapses it | None | Alt+T focuses a toast and pauses, Esc resumes | Alt+T focuses the newest toast, Esc dismisses it | | Screen reader | One polite region for all toasts | Each toast polite status | Each toast role="alert" | Each toast polite status, errors alert | | Phone layout | Full width below 600px | None built in | Full width below 480px, safe areas | Full width below 768px, safe areas | | Reduced motion | Transitions off | Fades instead of sliding | No rule in the package | Fades instead of sliding | | Your own markup | unstyled, toast.custom | react-hot-toast/headless | react-toastify/unstyled | Edit components/ui/toast.tsx | The Wingo UI size needs a footnote: in most apps it is the heaviest of the four, because it animates with motion. In a shadcn-style project that already ships tailwind-merge, lucide-react and Radix, it adds 57.6 kB, motion included. If that app already uses motion too, it adds only its own 12.2 kB, and with nothing shared it is 69 kB, seven times Sonner. Its styles are Tailwind classes that compile into your app's stylesheet, so they are not in these numbers. Sonner vs react hot toast: which one should you use? Use Sonner if you want a stack, swipe and action buttons without writing them. Use react-hot-toast if you want the smallest package and plan to render toasts your own way. The two look alike at the call site until you need a button: // sonner: action and cancel are options import { toast } from "sonner"; export function onDelete(restore: () => void) { toast("Listing deleted", { description: "City bike, Trek FX 2", action: { label: "Undo", onClick: restore }, }); } // react-hot-toast: no action option, so the button is your markup import toast from "react-hot-toast"; export function onDelete(restore: () => void) { toast((t) => ( <span> Listing deleted <button onClick={() => { restore(); toast.dismiss(t.id); }} > Undo </button> </span> )); } Beyond buttons, react-hot-toast lists toasts instead of stacking them, has no swipe and no keyboard handling, and pauses its timers only on mouse enter and leave, not for keyboard focus or a background tab. Success toasts default to 2 seconds, which is short for an Undo. Its strength is the headless entry: useToaster from react-hot-toast/headless hands you the toasts plus startPause, endPause and calculateOffset, the right shape if you want full control of the markup and will write the gestures yourself. Sonner gives you polished defaults: a 356px stack, swipe in the directions its position allows, toast.promise, richColors and a close button, with timers that pause while you hover, while you swipe and while the tab is in the background. Its look comes from its own stylesheet, so matching a design system means CSS variables and classNames, or unstyled and your own markup. When is React-Toastify the better pick? Pick React-Toastify when you need features the others leave out: a progress bar on every toast, on by default, and a controlled progress value for uploads. limit holds extra toasts in a waiting queue, toast.update(id, options) changes a toast in place, stacked collapses the list, and the useNotificationCenter add-on keeps a history of toasts for an inbox. It handles RTL and positions its container with env(safe-area-inset-*) on phones. Its defaults are the loudest of the four: a close button and a progress bar on every toast, 5 seconds on screen, and role="alert" on each toast, so screen readers interrupt the user for a "Saved" message unless you pass role="status". We found no prefers-reduced-motion rule in the 11.1.0 package, so the default Bounce transition plays for everyone; pass transition={Slide} or your own cssTransition if that matters to you. If those defaults or the styling are why you want a React Toastify alternative, Sonner is the closest swap for the call sites (toast.success, toast.error, toast.promise). The main rename is the promise's loading message: React-Toastify calls it pending and Sonner calls it loading. A history with read state is an inbox, a different job: Wingo UI keeps it in a separate Notification Center with a bell and an unread count. What does "shadcn toast" mean in 2026? It depends on the project's base. Since July 2026 new shadcn/ui projects start on Base UI, and for those npx shadcn@latest add toast installs a Toast built on Base UI primitives, called with toast.add({ title, description }) and toast.promise. In Radix and React Aria projects, the old Toast is marked deprecated and the docs point to Sonner: npx shadcn@latest add sonner writes a components/ui/sonner.tsx that wraps the package, maps its colors to your --popover and --border tokens and reads the theme from next-themes. So the shadcn toast in a Radix project is Sonner with your colors: you own a short wrapper, and the stack, timers and swipe stay in node_modules/sonner. The Base UI version hands you more of the markup, with behavior from Base UI's Toast parts. Per the Base UI docs, that means a 5 second default timeout, a limit of 3, swipe down or right, a priority: "high" option for urgent announcements, and F6 to move focus into the toast region. What changes on a phone? The phone is where these libraries differ most, on four points: - Width. Sonner spans the screen below 600px with 16px gutters (mobileOffset). React-Toastify spans it below 480px with square corners. react-hot-toast has no phone layout: its toasts cap at 350px and sit 16px from the edges. - Buttons. Sonner's default action and cancel buttons are 24px tall, exactly WCAG 2.2's minimum target size, and its close button is 20px, under it. All of them are far from the 44pt Apple recommends for iOS controls; our guide to minimum touch target size has the numbers. - Safe areas. React-Toastify positions its container with the safe area insets. Sonner and react-hot-toast do not, so with viewport-fit=cover a bottom stack can sit on the iPhone home indicator until you add the inset to the offset yourself. Tailwind safe area insets shows the pattern. - Swipe. Sonner dismisses after 45px of travel or a quick flick. React-Toastify swipes on touch only by default and wants 80% of the toast's width. react-hot-toast has no swipe. We built the Wingo UI Toast around that list. Below 768px toasts span the width with 16px gutters, each edge uses max(env(safe-area-inset-*), 16px), and mobilePosition="top" moves them clear of a bottom tab bar. Action and close buttons look 32px tall and keep a 44px hit area on touch screens. With no hover on a phone, a tap fans out the stack. > Live demo: Toast. The original article embeds an interactive demo here (Hover the stack to fan it out (tap it on a phone), then swipe the front toast toward the edge. Press Failed payment, then Retry: the same toast turns into a loading state and then a success). Try it live on wingo-ui.com or open the Toast docs. Install: npx wingo-ui@latest add toast How do the toasts sound to a screen reader? All four announce through ARIA live regions; the difference is what gets interrupted. A polite announcement waits until the screen reader finishes speaking. An assertive one, which role="alert" implies, cuts in. - Sonner wraps every toast in one <section aria-live="polite"> labeled "Notifications alt+T". A failed payment is announced as politely as "Copied". - react-hot-toast gives each toast role="status" and aria-live="polite", and you can switch a single toast to alert through its ariaProps option. - React-Toastify gives each toast role="alert", so every toast interrupts unless you change the role. - Wingo UI makes each toast its own polite status and switches errors, and any toast sent with important: true, to role="alert". For the keyboard, Sonner's Alt+T focuses the list and Escape collapses it, React-Toastify's focuses a toast and pauses every timer until Escape, and react-hot-toast has none. In the Wingo UI Toast, Alt+T (Option+T on a Mac) focuses the newest toast, Tab walks through its buttons, Escape dismisses the focused toast, and timers pause while keyboard focus is inside, so there is time to reach Undo. When should you skip the toast and use an inline alert? Skip it when the message has to be read or acted on. A declined card, a form that failed to save, or a session about to expire need a message that stays next to the problem. Toasts time out, sit far from the field that caused them, and on a phone they can cover the submit button. Use an inline alert for those and keep toasts for confirmations, progress and quick undos. The free Alert follows the same split for screen readers: danger and warning tones use role="alert", the others role="status". > Live demo: Alert. The original article embeds an interactive demo here (A declined card belongs next to the Pay button: press Try again and the alert collapses, then press Pay to see it expand back in). Try it live on wingo-ui.com or open the Alert docs. Install: npx wingo-ui@latest add alert What does a source-owned toast give you? A source-owned toast puts the whole component in your repository: the store, the stack, the gesture and the markup. You review it like the rest of your code, and nothing changes under you on npm update. The cost is that upstream fixes reach you only when you pull them. The Wingo UI Toast is free; install it with the CLI: npx wingo-ui@latest add toast That writes components/ui/toast.tsx plus the button, the spinner and the hooks and helpers it uses. Mount the Toaster once, next to the app: // app/providers.tsx "use client"; import type { ReactNode } from "react"; import { Toaster } from "@/components/ui/toast"; export function Providers({ children }: { children: ReactNode }) { return ( <> {children} <Toaster richColors mobilePosition="top" /> </> ); } Then call toast from anywhere, even outside React or before the Toaster mounts: import { toast } from "@/components/ui/toast"; export function onDelete(restore: () => void) { toast("Listing deleted", { description: "City bike, Trek FX 2", action: { label: "Undo", onClick: restore }, }); } export async function publishListing(publish: () => Promise<{ price: string }>) { // returns the original promise: await gives you the data or throws the error return toast.promise(publish, { loading: "Publishing the listing…", success: (data) => `Published for ${data.price}`, error: "Could not publish the listing", }); } Beyond those call sites, the file gives you: - Updates by id. Calling toast.loading("Uploading 3 photos… 2/3", { id: "upload" }) again updates the toast in place. A new type with the same id, such as toast.success, starts it over, so the loading toast's Infinity duration never sticks to the success. - Your tokens. The surface uses bg-popover and your theme's status tones, so dark mode needs no theme prop. Visual props such as radius, width and richColors can also be set app-wide through UIProvider defaults. - Updates that keep your edits. npx wingo-ui@latest update merges a new release into the file you changed, as the updates docs describe. The trade-offs, stated plainly. It is younger than the three packages, and Sonner has years of bug reports behind it. It costs more bytes than any of them, even in an app that already uses motion. In a shadcn project created with a Base UI style, install it with the Wingo UI CLI instead of shadcn add, because that style rewrites asChild, which the button relies on. The Notification Center is part of Wingo UI Pro; the Toast and the Alert are free. How do you move from Sonner to the Wingo UI Toast? Most call sites only change the import. The API follows Sonner's: toast, toast.success, error, warning, info and loading, toast.promise, toast.custom, toast.dismiss, the action, cancel and toasterId options, and the position, expand, visibleToasts, duration, richColors and closeButton props keep their names and defaults. The differences: | Sonner | Wingo UI Toast | | --- | --- | | import { toast } from "sonner" | import { toast } from "@/components/ui/toast" | | hotkey={["altKey", "KeyT"]} | hotkey="alt+t" | | <Toaster id="editor" /> for a second toaster | <Toaster toasterId="editor" /> | | toast.promise(...).unwrap() for the result | toast.promise(...) returns the original promise | | theme prop, or next-themes in the shadcn wrapper | None; the surface uses your tokens | | event.preventDefault() in an action keeps the toast open | The same, or keepOpen: true on the action | | mobileOffset below 600px | mobileOffset below 768px, plus mobilePosition | | toast.message, toast.getHistory | Not available; useToasts() returns the toasts on screen | If you used the shadcn wrapper, delete components/ui/sonner.tsx, point the Toaster import at @/components/ui/toast, and replace from "sonner" in the files that call toast. Then drop any theme prop from the Toaster and the .unwrap() after promise toasts, which TypeScript flags. Where should you start? If Sonner works and nobody has complained, keep it. If the complaint is about phones, screen readers or a design system that will not bend, try a source-owned toast: run npx wingo-ui@latest add toast, which is free and needs no account, swap the import on a few call sites, and open the app on your own phone. Components in this post - Toast (Free): Toast notifications: a Toaster mounted once and a toast() you call from anywhere, stacked, swipeable and paused while you read. Install: npx wingo-ui@latest add toast - Alert (Free): An inline message about a page or a section, from a failed payment to a maintenance banner, in three variants and six tones. Install: npx wingo-ui@latest add alert FAQ What is the best Sonner alternative for React? It depends on why you are leaving. react-hot-toast is the smallest and has a headless hook, React-Toastify has the most built-in features, and a source-owned toast like the free Wingo UI Toast keeps a Sonner-style API while every behavior lives in a file you can edit. Is Sonner better than react-hot-toast? Sonner does more out of the box: a collapsed stack, swipe to dismiss, action and cancel buttons, a promise toast and an Alt+T shortcut. react-hot-toast is about half the size at 4.8 kB gzip in our build and ships a headless useToaster hook, but it has no swipe, no keyboard shortcut and no built-in action buttons. Does shadcn/ui still use Sonner for toasts? It depends on the project's base. In Radix and React Aria projects the old Toast is deprecated and npx shadcn@latest add sonner installs a short wrapper around the sonner package. In Base UI projects, the default for new projects since July 2026, npx shadcn@latest add toast installs a Toast built on Base UI with a toast.add() API. Which React toast library is the smallest? react-hot-toast, at 4.8 kB minified and gzipped in our esbuild measurement in October 2026. Sonner came to 9.6 kB and React-Toastify to 9.8 kB, both including the CSS they inject from JavaScript. Are toast notifications accessible to screen readers? They are announced through ARIA live regions, but the libraries differ in urgency: Sonner announces everything politely, React-Toastify marks every toast role="alert" by default, and react-hot-toast and the Wingo UI Toast let single toasts interrupt. Anything the user must act on belongs in an inline alert, because toasts time out. --- This article was originally published on the Wingo UI blog: Sonner Alternative for React: 4 Toast Libraries Compared. The original has the live, interactive demos and is kept up to date.

  5. Just noticed my skills repo is at 50,000 stars and 2,7 million downloads, thanks! More skills coming soon, you’ll like them. emilkowalski/skills

    735 likes · 17 reposts · 17,634 views
  6. 📌영상 속 도구 링크 [CH1 AI 디자인 스킬] 프론트엔드 디자인: github.com/anthropics/skills/tree/main/skills/fro… UI UX 프로 맥스: nextlevelbuilder/ui-ux-pro-max-skill 테이스트 스킬: Leonxlnx/taste-skill 홀마크: Nutlope/hallmark 임페커블: pbakaus/impeccable (impeccable.style) [CH2 작업공간] 구글 스티치: stitch.withgoogle.com 오픈디자인: nexu-io/open-design [CH3 웹 인터랙션] 에밀 코왈스키 스킬: emilkowalski/skills 리액트 비츠: reactbits.dev (DavidHDev/react-bits) 키프레임 갤러리: keyframe.gallery [CH4 모션그래픽] 리모션: remotion.dev (remotion-dev/remotion) 하이퍼프레임즈: heygen-com/hyperframes 클로드 모션: claude.com 어썸 오푸스 5.5 비디오스: yihui-dev/awesome-opus5-5-videos

    1 like
  7. YouTube · · YouTube description · 민둥이 | 쉽게 배우는 AI · emilkowalski/skills

    개발자들이 선택한 깃허브 디자인·모션 도구 TOP 14 웹사이트 UI 디자인부터 인터랙션, 모션그래픽, 3D 애니메이션까지. 깃허브에서 인기 있고 실제로 쓸 만한 AI 디자인 스킬과 도구 14개를 네 챕터로 정리했습니다. 필요한 챕터만 골라 보세요. 00:00 인트로 00:19 CH1 AI 디자인 스킬 00:19 프론트엔드 디자인 (Anthropic) 00:45 UI UX 프로 맥스 01:11 테이스트 스킬 01:33 홀마크 01:57 임페커블 02:28 CH2 작업공간 02:31 구글 스티치 02:55 오픈디자인 03:19 CH3 웹 인터랙션 03:22 에밀 코왈스키 애니메이션 스킬 03:49 리액트 비츠 04:09 키프레임 갤러리 04:23 CH4 모션그래픽 04:27 리모션 04:44 하이퍼프레임즈 05:06 클로드 모션 05:32 어썸 오푸스 5.5 비디오스 05:51 마무리 ▶ 영상 속 도구 링크 [CH1 AI 디자인 스킬] 프론트엔드 디자인: github.com/anthropics/skills/tree/main/skills/fro… UI UX 프로 맥스: nextlevelbuilder/ui-ux-pro-max-skill 테이스트 스킬: Leonxlnx/taste-skill 홀마크: Nutlope/hallmark 임페커블: pbakaus/impeccable (impeccable.style) [CH2 작업공간] 구글 스티치: stitch.withgoogle.com 오픈디자인: nexu-io/open-design [CH3 웹 인터랙션] 에밀 코왈스키 스킬: emilkowalski/skills 리액트 비츠: reactbits.dev (DavidHDev/react-bits) 키프레임 갤러리: keyframe.gallery [CH4 모션그래픽] 리모션: remotion.dev (remotion-dev/remotion) 하이퍼프레임즈: heygen-com/hyperframes 클로드 모션: claude.com 어썸 오푸스 5.5 비디오스: yihui-dev/awesome-opus5-5-videos [en-US] TOP 14 GitHub Design & Motion Tools Chosen by Developers From website UI design to interactions, motion graphics, and 3D animation. We have organized 14 popular and practically useful AI design skills and tools from GitHub into four chapters. Pick only the chapters you need. 00:00 Intro 00:19 CH1 AI Design Skills 00:19 Frontend Design (Anthropic) 00:45 UI UX Pro Max 01:11 Taste Skills 01:33 Hallmark 01:57 Impeccable 02:28 CH2 Workspace 02:31 Google Stitch 02:55 OpenDesign 03:19 CH3 Web Interactions 03:22 Emil Kowalski Animation Skills 03:49 React Beats 04:09 Keyframe Gallery 04:23 CH4 Motion Graphics 04:27 Remotion 04:44 Hyperframes 05:06 Claude Motion 05:32 Awesome Opus 5.5 Videos 05:51 Wrap-up ▶ Links to tools in the video [CH1 AI Design Skills] Frontend Design: github.com/anthropics/skills/tree/main/skills/fro… UI UX Pro Max: nextlevelbuilder/ui-ux-pro-max-skill Taste Skills: Leonxlnx/taste-skill Hallmark: Nutlope/hallmark Impeccable: pbakaus/impeccable (impeccable.style) [CH2 Workspace] Google Stitch: stitch.withgoogle.com Open Design: nexu-io/open-design [CH3 Web Interactions] Emil Kowalski Skills: emilkowalski/skills React Bits: reactbits.dev (DavidHDev/react-bits) Keyframes Gallery: keyframe.gallery [CH4 Motion Graphics] Remotion: remotion.dev (remotion-dev/remotion) Hyperframes: heygen-com/hyperframes Claude Motion: claude.com Awesome Opus 5.5 Videos: yihui-dev/awesome-opus5-5-videos [id] 14 Alat Desain & Motion GitHub Pilihan Para Developer Mulai dari desain UI situs web hingga interaksi, grafis gerak, dan animasi 3D. Kami telah mengorganisir 14 keterampilan dan alat desain AI yang populer dan praktis dari GitHub ke dalam empat bab. Pilih hanya bab yang Anda butuhkan. 00:00 Pendahuluan 00:19 CH1 Keterampilan Desain AI 00:19 Desain Frontend (Anthropic) 00:45 UI UX Pro Max 01:11 Taste Skills 01:33 Hallmark 01:57 Impeccable 02:28 CH2 Workspace 02:31 Google Stitch 02:55 OpenDesign 03:19 CH3 Interaksi Web 03:22 Keterampilan Animasi Emil Kowalski 03:49 React Beats 04:09 Galeri Keyframe 04:23 CH4 Grafis Gerak 04:27 Remotion 04:44 Hyperframes 05:06 Claude Motion 05:32 Video Awesome Opus 5.5 05:51 Penutup ▶ Tautan ke alat dalam video [CH1 Keterampilan Desain AI] Desain Frontend: github.com/anthropics/skills/tree/main/skills/fro… UI UX Pro Max: nextlevelbuilder/ui-ux-pro-max-skill Taste Skills: Leonxlnx/taste-skill Hallmark: Nutlope/hallmark Impeccable: pbakaus/impeccable (impeccable.style) [Ruang Kerja Bab 2] Google Stitch: stitch.withgoogle.com Open Design: nexu-io/open-design [Interaksi Web Bab 3] Emil Kowalski Skills: emilkowalski/skills React Bits: reactbits.dev (DavidHDev/react-bits) Galeri Keyframes: keyframe.gallery [Grafik Gerak CH4] Remotion: remotion.dev (remotion-dev/remotion) Hyperframes: heygen-com/hyperframes Claude Motion: claude.com Video Awesome Opus 5.5: yihui-dev/awesome-opus5-5-videos

    165 likes · 3 comments · 11,179 views
  8. emilkowalski/skills

  9. emilkowalski/skills

  10. Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones TL;DR A shadcn responsive dialog renders the Dialog above 768px and the Drawer, a bottom sheet you can drag down, below it. Put that switch inside one root component whose parts read the mode from context, so the content is written once and the open state survives a resize. Read the breakpoint with useSyncExternalStore so the server and the hydration render agree, and keep everything visible before the first tap identical in both modes, so nothing can flash. The shadcn responsive dialog is the pattern from the shadcn/ui Drawer docs: a centered Dialog on desktop and a Drawer, a bottom sheet you can drag down, on phones. The recipe works, but it has you write the trigger and the header twice and pick the branch with a hook that guesses on the first render. Both cause bugs once you make a shadcn dialog mobile friendly this way: the copies drift apart, and the branch flips after mount. This tutorial builds one dialog API that renders a card above 768px and a draggable sheet below it, with the content written once and nothing to flash during hydration. What does the shadcn responsive dialog recipe do? It renders two complete trees and picks one with a media query. As of October 2026 the shadcn Drawer docs say "You can combine the Dialog and Drawer components to create a responsive dialog", and the drawer-dialog example is shaped like this: const [open, setOpen] = React.useState(false) const isDesktop = useMediaQuery("(min-width: 768px)") if (isDesktop) { return <Dialog open={open} onOpenChange={setOpen}>{/* trigger, header, form */}</Dialog> } return <Drawer open={open} onOpenChange={setOpen}>{/* trigger, header, form, footer */}</Drawer> Keeping open above the branch is right: a resize across 768px keeps the dialog open. Three things cost you later: 1. Two copies. The trigger, title and description appear in both branches, and the Cancel footer exists only in the Drawer. Every copy change has to land twice. 2. A guess on every mount. The example's useMediaQuery starts with useState(false) and reads matchMedia in an effect. That is false on the server, which is fine, and also on the first client render of every later mount. On a desktop each mount renders the Drawer tree first and swaps after the effect, so a dialog that mounts already open (from a ?edit=1 URL, say) starts as the wrong component. 3. A remount on every flip. Dialog and Drawer are different component types, so a flip unmounts one tree and mounts the other, along with any state inside. Ready-made wrappers fix the first point. As of October 2026, Credenza (MIT) and Dice UI's Responsive Dialog wrap a dialog and a Vaul drawer in one set of parts. Credenza switches at 768px, and its useIsMobile also starts as false and flips in an effect, so it keeps the second problem. If you want to stay on stock shadcn/ui, use one of them or the wrapper below, and check what its hook returns before the first effect runs. Where should the switch between dialog and drawer live? In the root, once. The root reads the breakpoint, owns the open state and tells every part which mode it is in; each part renders the Dialog piece or the Drawer piece. This works because Vaul's Trigger, Close, Title and Description are Radix Dialog's own parts, so the trigger is the same button in both modes. Here is that responsive dialog drawer wrapper on top of the stock shadcn/ui Dialog and Drawer (Radix versions, type-checked against their source as of October 2026): "use client"; import { createContext, useContext, useState, type ComponentType, type ReactNode } from "react"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { cn } from "@/lib/utils"; const SheetMode = createContext(false); export function ResponsiveDialog({ children }: { children: ReactNode }) { const sheet = useIsMobile(); // one open state above both trees, so crossing 768px keeps it open const [open, setOpen] = useState(false); return ( <SheetMode.Provider value={sheet}> {sheet ? ( <Drawer open={open} onOpenChange={setOpen}>{children}</Drawer> ) : ( <Dialog open={open} onOpenChange={setOpen}>{children}</Dialog> )} </SheetMode.Provider> ); } type PartProps = { asChild?: boolean; className?: string; children?: ReactNode }; // each part renders the dialog piece or the drawer piece, plus the classes the drawer needs function part(Card: ComponentType<PartProps>, Sheet: ComponentType<PartProps>, sheetClass = "") { return function ResponsivePart({ className, ...props }: PartProps) { const sheet = useContext(SheetMode); const Part = sheet ? Sheet : Card; return <Part className={cn(sheet && sheetClass, className)} {...props} />; }; } const Div = ({ className, children }: PartProps) => <div className={className}>{children}</div>; export const ResponsiveDialogTrigger = part(DialogTrigger, DrawerTrigger); export const ResponsiveDialogClose = part(DialogClose, DrawerClose); export const ResponsiveDialogContent = part(DialogContent, DrawerContent); export const ResponsiveDialogHeader = part(DialogHeader, DrawerHeader); export const ResponsiveDialogTitle = part(DialogTitle, DrawerTitle); export const ResponsiveDialogDescription = part(DialogDescription, DrawerDescription); // the drawer has no body padding and stacks its footer top down export const ResponsiveDialogBody = part(Div, Div, "px-4"); export const ResponsiveDialogFooter = part(DialogFooter, DrawerFooter, "flex-col-reverse"); The two extra classes matter. DrawerContent has no padding of its own, so the body needs px-4 in sheet mode. DrawerFooter stacks its buttons top down, so without flex-col-reverse the sheet would show Cancel above Save. With it, you write Cancel first and Save last, and Save lands on the right of the card and on top of the sheet. Leave the header alone: the drawer centers it with a group-data-[vaul-drawer-direction=bottom] selector, which outranks a plain text-left. That is also why the className="text-left" in the shadcn example changes nothing. How do you avoid a hydration flash with useMediaQuery? Make the server's answer and the first client answer the same, and keep everything visible before the first tap identical in both modes. A closed dialog renders only its trigger, and that trigger is the same Radix button in both trees, so the server can render it without knowing the screen width. The hook just has to agree with the server during hydration and be right on every render after it, which is what useSyncExternalStore does: // hooks/use-is-mobile.ts import { useSyncExternalStore } from "react"; const QUERY = "(max-width: 767.98px)"; function subscribe(onChange: () => void) { const list = window.matchMedia(QUERY); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); } export function useIsMobile() { return useSyncExternalStore( subscribe, () => window.matchMedia(QUERY).matches, // the server and the hydration render assume a desktop () => false, ); } React calls the third function on the server and during hydration, so the two match and there is no mismatch warning. Every render after that, including the first render of a dialog that mounts later, calls the second function and gets the real answer, with no effect in between. Phones correct the guess right after hydration: the root swaps Dialog for Drawer under a closed trigger, which changes nothing on screen. If you installed the shadcn Sidebar, hooks/use-mobile.ts already exports a useIsMobile built on useState and an effect; give it this body and the sidebar gets the same fix. We checked the "mounts later" case on the Wingo UI Dialog, which is built this way. Its playground loads the demo after hydration, so we set the dialog to open on load and sampled the page on every animation frame in Chromium through Playwright at 390px. From the first frame the dialog existed it was the sheet, never the centered card, and the console stayed empty. What still flashes is visible markup that branches on the hook, such as a form inline on desktop and behind a button on phones. Switch that with CSS breakpoints instead; the mobile-first React components guide has the right and wrong versions side by side. The free useMediaQuery hook is this snippet generalized to any query with a serverFallback parameter, and it exports the same useIsMobile. Our SSR-safe React use mobile hook tutorial compares it with the useState and typeof window versions through a real hydration. How do you build it with the Wingo UI Dialog? Install the free Dialog and write it once: it does the switch itself through a responsive prop that defaults to true. The root reads the breakpoint through useSyncExternalStore, as above, and renders a Radix dialog from 768px or the Drawer's bottom sheet below it, and every part (header, title, description, body, footer) renders the matching piece. 1. Install the dialog npx wingo-ui@latest add dialog The CLI copies components/ui/dialog.tsx and what it imports, such as the Drawer, the media query hook, the Button and the motion helpers. All of them are free. 2. Write the content once "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; type Profile = { name: string; email: string }; export function EditProfileDialog({ profile, onSave }: { profile: Profile; onSave: (next: Profile) => Promise<void> }) { const [open, setOpen] = useState(false); // the draft lives above the dialog, so crossing 768px while it is open keeps what was typed const [draft, setDraft] = useState(profile); const [saving, setSaving] = useState(false); async function save() { setSaving(true); try { await onSave(draft); setOpen(false); } finally { setSaving(false); } } return ( <Dialog open={open} onOpenChange={setOpen} trigger={ <Button variant="soft" onClick={() => setDraft(profile)}> Edit profile </Button> } title="Edit profile" description="Your name and email show on every invoice." footer={ <> <DialogClose asChild> <Button variant="soft">Cancel</Button> </DialogClose> <Button loading={saving} loadingText="Saving" onClick={save}> Save </Button> </> } > <div className="flex flex-col gap-4"> <Input label="Name" autoComplete="name" value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} /> <Input label="Email" type="email" autoComplete="email" value={draft.email} onValueChange={(email) => setDraft({ ...draft, email })} /> </div> </Dialog> ); } The primary action goes last in the footer. On desktop it lands on the right; on the phone sheet the buttons stack full width at 48px with Save on top. 3. Open it at both widths Try the playground below on a laptop, then on a phone or with the window narrowed under 768px before you tap. > Live demo: Dialog. The original article embeds an interactive demo here (Tap Edit: on a wide screen the card scales in and focuses the first field; below 768px the same dialog opens as a bottom sheet you can drag down to close). Try it live on wingo-ui.com or open the Dialog docs. Install: npx wingo-ui@latest add dialog Below 768px the same JSX changes behavior as well as layout: - Dismissal. A handle appears, the sheet follows your finger, and letting go past a quarter of its height or flicking it closes it. - Focus. The desktop card focuses the first field. The sheet focuses itself, so the keyboard does not cover half the sheet before the person picks a field; Tab moves on from the top. - Scrolling. A long body scrolls first and only drags the sheet once it is back at the top, and the bottom pads the home indicator. - Size. size="full" is a 92% tall sheet on phones and a card 16px from every edge on desktop. classNames apply in both modes, and the sheet's parts keep the data-slot="dialog-*" names, so CSS or tests that target [data-slot=dialog-footer] match the card and the sheet alike. 4. Keep form state above the dialog Crossing 768px keeps the dialog open but remounts its content, so state that lives inside it resets. We typed into the playground's title field at 1024px and resized the window to 390px: the sheet was open and the field was back to its original value. The shadcn example and the wrapper above behave the same way, because the root changes component type. The real case is a tablet rotated across the breakpoint, such as an iPad mini, which is 744px wide in portrait and 1133px in landscape. The fix is the one in step 2: keep the draft in the component that renders the dialog. Inside the dialog, useDialog() returns { open, setOpen, sheet } if a part needs a different layout on the sheet. 5. Opt out where a sheet is wrong Pass responsive={false} to keep a centered card on phones, or set it for the whole app with <UIProvider defaults={{ dialog: { responsive: false } }}>. The Alert Dialog never becomes a sheet: on a phone a destructive question stays a centered card with its buttons stacked full width. It is part of Wingo UI Pro. Shadcn drawer vs sheet: which one should you use? The Drawer is a bottom sheet with a drag gesture; the Sheet is a panel that slides in from an edge on a CSS animation, with nothing to drag. Both can open from any edge, the Drawer from the bottom by default and the Sheet from the right. Use the Drawer, or a responsive dialog, for tasks and choices on phones, and the Sheet for content that sits beside the page on desktop, such as navigation, filters or a cart. As of October 2026: | Component | Built on | Drag to close | From 768px | | --- | --- | --- | --- | | shadcn Drawer (Radix) | Vaul | Yes | The same drawer | | shadcn Sheet | Radix Dialog | No | A side panel | | Wingo UI Dialog | Radix Dialog + Drawer | On phones | A centered card | | Wingo UI Drawer | Vaul | Yes | Sheet, side panel or dialog | | Wingo UI Sheet | Radix Dialog + motion | On touch screens | A side panel | On the same date, Vaul's README says "This repo is unmaintained", and the Base UI docs of the shadcn Drawer say it "now uses Base UI instead of Vaul". The Wingo UI Drawer, and with it the Dialog's phone mode, still runs on Vaul 1.1.2. Snap points, nested sheets and the keyboard inside a sheet are covered in our React bottom sheet guide. The Sheet covers the other common case: a filter panel from the right on desktop that becomes a bottom sheet on phones, through mobileSide="bottom". > Live demo: Sheet. The original article embeds an interactive demo here (Tap Filters: on a wide screen the panel slides in from the right; below 768px it rises from the bottom, and on a touch screen you drag its handle down to close it). Try it live on wingo-ui.com or open the Sheet docs. Install: npx wingo-ui@latest add sheet Modal vs bottom sheet: when should a phone still get a centered card? Use a bottom sheet for tasks and choices someone finishes and returns from, and keep a centered card for a short question that must be answered. Apple's Human Interface Guidelines describe a sheet as "useful for requesting specific information from people or presenting a simple task that they can complete before returning to the parent view", and add: "For complex or prolonged user flows, consider alternatives to sheets." The rules we follow: - Forms, pickers, filters and share options: a bottom sheet. It opens where the thumb already is and closes with the same motion. - Destructive confirmations such as "Delete 3 invoices?": a centered alert dialog. A sheet's drag to dismiss makes a question that needs an answer too easy to wave away. - Long editors and multi-step flows: a full-height sheet (size="full") or a page of its own. More patterns like these live in the Mobile UI in React category. What should you do next? Install the free Dialog with npx wingo-ui@latest add dialog, open your app at 390px and drag a sheet closed. If you stay on stock shadcn/ui, copy the wrapper and the useSyncExternalStore hook above into your project. Components in this post - Dialog (Free): The modal for focused tasks: a spring-scaled card on desktop that becomes a bottom sheet on phones, with nesting and a scrolling body. Install: npx wingo-ui@latest add dialog - Drawer (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: npx wingo-ui@latest add drawer - Sheet (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: npx wingo-ui@latest add sheet - useMediaQuery (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: npx wingo-ui@latest add use-media-query FAQ How do I make a shadcn dialog responsive on mobile? Render the Drawer below 768px and the Dialog above it, from one root that owns the open state and parts that pick the matching Drawer or Dialog piece. The shadcn docs show a two-branch version; a context-based wrapper, Credenza, Dice UI or the Wingo UI Dialog keep the content in one place. What is the difference between the shadcn Drawer and Sheet? The Drawer is a bottom sheet you can drag to close, built on Vaul in the Radix version and on Base UI in the Base UI version. The Sheet extends the Dialog to slide a panel in from any edge; the Radix version animates with CSS and has no drag gesture. Why does my responsive dialog show the wrong component for a moment? Many media query hooks, including the one in the shadcn example, start as false and correct themselves in an effect, so the first render of every mount uses a guess. Read the query with useSyncExternalStore and never branch on it for anything visible before the dialog opens. At what width should a dialog become a bottom sheet? 768px, Tailwind's md breakpoint, is the usual choice: the shadcn example, Credenza and Wingo UI all switch there. Below it a centered card leaves little room above the on-screen keyboard and puts its buttons far from the thumb. Is the Wingo UI Dialog free? Yes. The Dialog, Drawer, Sheet and the useMediaQuery hook are free, and npx wingo-ui@latest add dialog also installs the Drawer it uses below 768px. The Alert Dialog, which stays a centered card on phones, is Pro. --- This article was originally published on the Wingo UI blog: Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones. The original has the live, interactive demos and is kept up to date.

Achievements

Awards earned by the repositories emilkowalski owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.