پرتوپرتو

الگوهای ریسپانسیو

راهنمای ساختاردهی رابط کاربری برای نمایش صحیح در موبایل، تبلت، و دسکتاپ

مقدمه

محصولات سوشیال لیسنینگ در دستگاه‌های مختلف استفاده می‌شوند. در پارتو UI، رویکرد پیش‌فرض mobile-first است: ابتدا نمایش موبایل تعریف می‌شود، سپس در نقاط شکست بزرگ‌تر بازنویسی می‌شود.


نمونه بصری

همان قطعهٔ داشبورد با دستور «موبایل-اول»: ردیف متریک‌ها از یک ستون شروع می‌شود و در sm دو ستونه و در xl چهار ستونه می‌شود؛ ستون اصلی و جانبی زیر lg روی هم می‌افتند. عرض پیش‌نمایش (یا پنجره) را تغییر دهید تا جابه‌جایی نقاط شکست را ببینید.

کل منشن‌ها

۱۲٬۴۸۰

نرخ تعامل

۴٫۶٪

دیدگاه مثبت

۶۸٪

پست‌های امروز

۳۲۴

موضوع‌های پربحث

کمپین تخفیف فصلیمثبت
بازخورد بسته‌بندیخنثی
رونمایی محصول جدیدمثبت

خلاصه هفته

گفت‌وگوهای برند این هفته ۸٪ رشد داشته است؛ بیشترین سهم از کمپین تخفیف فصلی است.

نقاط شکست (Breakpoints)

پارتو UI از نقاط شکست استاندارد Tailwind پیروی می‌کند:

نامحداقل عرضموارد استفاده
پیش‌فرض (موبایل)نمایش موبایل‌اول
sm640pxتبلت کوچک
md768pxتبلت
lg1024pxلپ‌تاپ
xl1280pxدسکتاپ
2xl1536pxصفحات بزرگ

الگوی ۱ — چیدمان شبکه‌ای (Grid Layout)

کارت‌های متریک در موبایل تک‌ستونه، در تبلت دو‌ستونه، و در دسکتاپ چهارستونه نمایش داده می‌شوند.

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
  <MetricCard>
    <MetricCardHeader>
      <MetricCardLabel>دنبال‌کننده‌ها</MetricCardLabel>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>۱۲۵ هزار</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  <MetricCard>
    <MetricCardHeader>
      <MetricCardLabel>تعامل</MetricCardLabel>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>۴.۲٪</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  {/* ... */}
</div>

الگوی ۲ — سایدبار ریسپانسیو

در موبایل، سایدبار به‌صورت کشویی (Sheet) نمایش داده می‌شود. در دسکتاپ، در کنار محتوا قرار می‌گیرد.

اگر یک پوستهٔ کامل می‌سازید

AppShell همین رفتار ریسپانسیو را از قبل بسته دارد و روش کانونیکال ساخت پوستهٔ برنامه است. الگوی زیر را وقتی به کار ببرید که به سایدبارِ بازشونده با گروه‌های متنی نیاز دارید، نه نوار آیکونی باریک — یا وقتی فقط یک ناحیهٔ ریسپانسیو می‌خواهید و نه کل پوسته.

import {
  SidebarProvider,
  Sidebar,
  SidebarTrigger,
  SidebarInset,
  SidebarContent,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
} from '@partodata/ui'
import { BarChart2, Users, Settings } from 'lucide-react'

export function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Sidebar>
        <SidebarContent>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton isActive>
                <BarChart2 />
                داشبورد
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton>
                <Users />
                اینفلوئنسرها
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton>
                <Settings />
                تنظیمات
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarContent>
      </Sidebar>

      <SidebarInset>
        {/* دکمه باز/بسته کردن سایدبار فقط در موبایل نمایش داده می‌شود */}
        <header className="flex items-center gap-2 border-b p-4 md:hidden">
          <SidebarTrigger />
          <span className="font-semibold">پارتو</span>
        </header>

        <main className="flex-1 p-4 md:p-6">{children}</main>
      </SidebarInset>
    </SidebarProvider>
  )
}

الگوی ۳ — جدول داده در موبایل

جداول داده در صفحات کوچک باید با اسکرول افقی قابل مشاهده باشند.

import { DataTable, type DataTableColumn } from '@partodata/ui'

// ۱. جدول با scroll افقی برای موبایل
;<div className="overflow-x-auto">
  <DataTable columns={columns} data={data} />
</div>

// ۲. پنهان کردن ستون‌های کم‌اهمیت در موبایل
const columns: DataTableColumn<Row>[] = [
  { id: 'name', header: 'نام', cell: (row) => row.name },
  {
    id: 'followers',
    header: 'دنبال‌کننده‌ها',
    cell: (row) => row.followers.toLocaleString('en-US'),
    // این ستون در موبایل پنهان می‌شود (با className)
    className: 'hidden sm:table-cell',
  },
  {
    id: 'engagement',
    header: 'تعامل',
    cell: (row) => `${row.engagement}٪`,
    className: 'hidden md:table-cell',
  },
  {
    id: 'status',
    header: 'وضعیت',
    cell: (row) => <Badge>{row.status}</Badge>,
  },
]

الگوی ۴ — کارت‌های پروفایل ریسپانسیو

در موبایل، اطلاعات پروفایل به‌صورت عمودی و در دسکتاپ به‌صورت افقی نمایش داده می‌شوند.

import { Avatar, AvatarImage, AvatarFallback, Badge, SocialPlatformBadge } from '@partodata/ui'

function InfluencerProfileHeader({ influencer }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
      {/* اطلاعات اصلی */}
      <div className="flex items-center gap-4">
        <Avatar className="size-16">
          <AvatarImage src={influencer.avatar} />
          <AvatarFallback>{influencer.name[0]}</AvatarFallback>
        </Avatar>

        <div className="flex flex-col gap-1">
          <h1 className="text-xl font-bold">{influencer.name}</h1>
          <div className="flex flex-wrap items-center gap-2">
            <SocialPlatformBadge platform={influencer.platform} />
            <Badge variant="outline">@{influencer.username}</Badge>
          </div>
        </div>
      </div>

      {/* آمار — در موبایل زیر اطلاعات، در دسکتاپ کنار */}
      <div className="grid grid-cols-3 gap-4 rounded-lg border p-4 sm:flex sm:gap-8">
        <div className="text-center">
          <p className="text-lg font-bold">{influencer.followers.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">دنبال‌کننده</p>
        </div>
        <div className="text-center">
          <p className="text-lg font-bold">{influencer.following.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">دنبال‌کننده</p>
        </div>
        <div className="text-center">
          <p className="text-lg font-bold">{influencer.posts.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">پست</p>
        </div>
      </div>
    </div>
  )
}

الگوی ۵ — فیلترها و جستجو در موبایل

در موبایل، فیلترها در یک Drawer نمایش داده می‌شوند تا فضای صفحه را اشغال نکنند.

'use client'

import { useState } from 'react'
import {
  Button,
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerFooter,
  DrawerClose,
  SearchInput,
  ToggleGroup,
  ToggleGroupItem,
} from '@partodata/ui'
import { SlidersHorizontal } from 'lucide-react'

function SearchWithFilters() {
  const [filtersOpen, setFiltersOpen] = useState(false)
  const [activeFilters, setActiveFilters] = useState<string[]>([])

  const filterOptions = [
    { label: 'اینستاگرام', value: 'instagram' },
    { label: 'تیک‌تاک', value: 'tiktok' },
    { label: 'میکرو اینفلوئنسر', value: 'micro' },
    { label: 'ماکرو اینفلوئنسر', value: 'macro' },
  ]

  return (
    <div className="flex flex-col gap-3">
      {/* نوار جستجو + دکمه فیلتر */}
      <div className="flex gap-2">
        <SearchInput placeholder="جستجوی اینفلوئنسر..." className="flex-1" />
        {/* دکمه فیلتر — در موبایل نمایش داده می‌شود */}
        <Button variant="outline" size="sm" className="md:hidden" onClick={() => setFiltersOpen(true)}>
          <SlidersHorizontal className="size-4" />
          فیلترها
        </Button>
      </div>

      {/* فیلترهای inline — فقط در دسکتاپ */}
      <div className="hidden flex-wrap gap-2 md:flex">
        <ToggleGroup type="multiple" value={activeFilters} onValueChange={setActiveFilters}>

          {filterOptions.map((filter) => (

            <ToggleGroupItem key={filter.value} value={filter.value}>

              {filter.label}

            </ToggleGroupItem>

          ))}

        </ToggleGroup>
      </div>

      {/* Drawer فیلتر برای موبایل */}
      <Drawer open={filtersOpen} onOpenChange={setFiltersOpen} direction="bottom">
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>فیلترها</DrawerTitle>
          </DrawerHeader>

          <div className="flex flex-wrap gap-2 p-4">
            <ToggleGroup type="multiple" value={activeFilters} onValueChange={setActiveFilters}>

              {filterOptions.map((filter) => (

                <ToggleGroupItem key={filter.value} value={filter.value}>

                  {filter.label}

                </ToggleGroupItem>

              ))}

            </ToggleGroup>
          </div>

          <DrawerFooter>
            <DrawerClose asChild>
              <Button>اعمال فیلترها</Button>
            </DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    </div>
  )
}

الگوی ۶ — نمودارها در موبایل

نمودارها باید با ارتفاع کمتر در موبایل نمایش داده شوند تا اطلاعات اصلی قابل مشاهده باشند.

import { PartoLineChart } from '@partodata/ui'

// ارتفاع نمودار بر اساس اندازه صفحه
function ResponsiveChart({ data }) {
  return (
    <div>
      {/* موبایل: ارتفاع کمتر */}
      <div className="block sm:hidden">
        <PartoLineChart data={data} height={200} />
      </div>
      {/* تبلت و دسکتاپ: ارتفاع معمول */}
      <div className="hidden sm:block">
        <PartoLineChart data={data} height={350} />
      </div>
    </div>
  )
}

نکات مهم

چه چیزی باید در موبایل پنهان شود؟

المانموبایلتبلتدسکتاپ
ستون‌های فرعی جدولپنهاننیمه‌پنهاننمایش
سایدبارDrawerثابت
فیلترهای inlineDrawerنیمهنمایش
نوار اعمال دسته‌ایBottom barToolbarToolbar
آمار پروفایلعمودیافقیافقی

از useIsMobile() با احتیاط استفاده کنید

این hook در سرور و اولین رندر کلاینت false برمی‌گرداند (مقدار داخلی پیش از mount با !! به false تبدیل می‌شود) و پس از mount مقدار واقعی را می‌دهد. یعنی دستگاه موبایل ابتدا برای یک فریم شاخهٔ دسکتاپ را رندر می‌کند:

import { useIsMobile } from '@partodata/ui'

function FiltersArea() {
  const isMobile = useIsMobile()

  // در SSR و اولین paint، مقدار همیشه false است؛
  // کاربر موبایل یک فریم FiltersInline را می‌بیند (فلش کوتاه).
  return isMobile ? <FiltersDrawer /> : <FiltersInline />
}

برای نمایش/پنهان‌سازی صرفاً بصری، به‌جای این hook از کلاس‌های breakpoint استفاده کنید (md:hidden، hidden md:flex) که در SSR هم بدون فلش درست رندر می‌شوند. useIsMobile() را برای تفاوت‌های رفتاری نگه دارید که با CSS قابل بیان نیستند (مانند Drawer در برابر فیلتر inline در الگوی ۵) و فلش یک‌فریمی را بپذیرید.

کلاس‌های RTL-safe برای responsive

// WRONG — physical
<div className="ml-4 sm:ml-0">

// CORRECT — logical (RTL-safe)
<div className="ms-4 sm:ms-0">

چه زمانی از کدام الگو استفاده کنید

  • جدول داده — همیشه overflow-x-auto + ستون‌های پنهان برای موبایل
  • فیلترها — Drawer در موبایل، inline در دسکتاپ
  • پروفایل — چیدمان عمودی در موبایل، افقی در دسکتاپ
  • داشبورد — شبکه ۱/۲/۴ ستونه بر اساس breakpoint
  • نمودارها — ارتفاع کمتر در موبایل

بهترین روش‌ها و دام‌های رایج

اشتباهات پرتکراری که در پیاده‌سازی صفحات ریسپانسیو دیده می‌شوند — هر مورد شامل اشتباه، دلیل، و الگوی درست است.

خواص فیزیکی CSS در واریانت‌های breakpoint

اشتباه: کپی‌کردن نمونه‌های LTR که کلاس‌های فیزیکی را پشت پیشوند breakpoint پنهان می‌کنند — مانند md:ml-auto، lg:pl-6 یا sm:text-left.

// ❌ غلط — فیزیکی؛ فقط در نمای دسکتاپِ RTL خراب می‌شود و در تست موبایل دیده نمی‌شود
<div className="flex flex-col md:flex-row">
  <Button className="md:ml-auto">ذخیره گزارش کمپین</Button>
</div>

// ✅ درست — خواص منطقی (logical) در همهٔ breakpointها
<div className="flex flex-col md:flex-row">
  <Button className="md:ms-auto">ذخیره گزارش کمپین</Button>
</div>

چرا دردسرساز است: کلاس فیزیکی‌ای که پشت پیشوند md: یا lg: پنهان شده فقط در همان عرض فعال می‌شود؛ اگر بازبینی فقط در نمای موبایل انجام شود، دکمه در دسکتاپ RTL به سمت اشتباه هل داده می‌شود و هیچ‌کس متوجه نمی‌شود. قانون سیستم طراحی «RTL Native» است: همیشه ms/me/ps/pe/text-start/text-end — در همهٔ واریانت‌های ریسپانسیو، نه فقط در کلاس‌های پایه. (در خود کتابخانه این قانون با قاعدهٔ ESLint سفارشی no-physical-css-properties اجباری شده است؛ در کد مصرف‌کننده باید خودتان مراقب باشید.)

رنگ hardcoded در المان‌های مخصوص موبایل

اشتباه: هدر یا نوار موبایل‌فقط (پشت md:hidden) با رنگ ثابت مانند bg-white یا border-gray-200 استایل می‌شود، چون «فقط یک نوار کوچک است».

// ❌ غلط — رنگ ثابت؛ در تم تیرهٔ پیش‌فرض، یک نوار سفید ناهماهنگ ظاهر می‌شود
<header className="flex items-center gap-2 border-b border-gray-200 bg-white p-4 md:hidden">
  <SidebarTrigger />
</header>

// ✅ درست — توکن‌های معنایی؛ در هر دو تم درست رندر می‌شود
<header className="flex items-center gap-2 border-b border-border bg-background p-4 md:hidden">
  <SidebarTrigger />
</header>

چرا دردسرساز است: تم پایهٔ پارتو تیره است — :root بدون هیچ تنظیمی توکن‌های تیره را حمل می‌کند و روشن انتخاب صریح است. یعنی bg-white دقیقاً در حالت پیش‌فرض هر مصرف‌کننده، و دقیقاً در نمای موبایل که کمتر بازبینی می‌شود، یک المان با تم مخالف نمایش می‌دهد. المان‌های breakpoint-فقط استثنا نیستند: قانون «بدون رنگ hardcoded» شامل آن‌ها هم می‌شود.

mount دوگانهٔ کامپوننت‌های داده‌محور در دو breakpoint

اشتباه: الگوی «دو بار رندر، یکی پنهان» (block sm:hidden / hidden sm:block) که برای نمودار presentational مناسب است، برای کامپوننتی که خودش داده fetch می‌کند نیز کپی می‌شود.

// ❌ غلط — هر دو نمونه mount می‌شوند: دو بار fetch و دو state صفحه‌بندی جداگانه
<div className="sm:hidden">
  <CampaignPostsTable pageSize={10} />
</div>
<div className="hidden sm:block">
  <CampaignPostsTable pageSize={50} />
</div>

// ✅ درست — یک نمونه با fetch واحد؛ تفاوت نمایش با className ستون‌ها (الگوی ۳)
<div className="overflow-x-auto">
  <DataTable columns={columns} data={rows} pagination={pagination} />
</div>

چرا دردسرساز است: کلاس hidden فقط نمایش را حذف می‌کند، نه mount را؛ هر دو نمونه درخواست شبکه می‌فرستند (دوبرابر شدن بار سرور) و state جداگانه نگه می‌دارند — کاربر در موبایل به صفحهٔ ۳ می‌رود، دستگاه را می‌چرخاند و ناگهان صفحهٔ ۱ نمای دسکتاپ را می‌بیند. رندر دوگانه فقط برای کامپوننت‌های presentational خالص (مانند نمودار الگوی ۶ که تنها prop ارتفاعش فرق دارد) قابل قبول است — و حتی آن‌جا هم fetch داده باید بیرون از دو شاخه انجام شود.

برش سمت کلاینت به‌جای صفحه‌بندی سرور در نمای کارت موبایل

اشتباه: در موبایل، جدول با یک لیست کارت جایگزین می‌شود که فقط rows.slice(0, 10) را نشان می‌دهد و صفحه‌بندی حذف می‌شود.

// ❌ غلط — نمای موبایل برای همیشه در ۱۰ ردیف اولِ همان یک صفحه‌ای که سرور برگردانده گیر می‌کند
{isMobile
  ? rows.slice(0, 10).map((row) => <PostCard key={row.id} {...row} />)
  : <DataTable columns={columns} data={rows} pagination={pagination} />}

// ✅ درست — fetch و state بیرون از دو شاخه است؛ هر دو نما از همان صفحه‌بندی سرور تغذیه می‌شوند
<div className="flex flex-col gap-3 sm:hidden">
  {rows.map((row) => (
    <PostCard key={row.id} {...row} />
  ))}
  <PaginationControlled currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
<div className="hidden sm:block">
  <DataTable
    columns={columns}
    data={rows}
    pagination={{ currentPage: page, totalPages, onPageChange: setPage, totalRows }}
  />
</div>

چرا دردسرساز است: DataTable سیستم طراحی سرور-محور است — طبق مستندات خودِ کامپوننت، totalRows را «طبق گزارش سرور» می‌گیرد چون از دادهٔ یک صفحه قابل استنتاج نیست. وقتی نمای موبایل به برش کلاینتی تنزل پیدا کند، کاربر موبایل فقط بخشی از اولین صفحهٔ دریافتی را می‌بیند؛ جستجو و مرتب‌سازی‌ای که سرور اعمال کرده با آنچه موبایل نشان می‌دهد واگرا می‌شود و دو کاربر روی دو دستگاه به دو «کل نتایج» متفاوت می‌رسند. الگوی درست همان است که در دام قبلی گفته شد: state صفحه‌بندی بالا نگه داشته می‌شود و دو شاخه فقط presentation هستند.

فارسی محاوره‌ای برای کوتاه‌کردن متن موبایل

اشتباه: برای صرفه‌جویی در فضای موبایل، برچسب‌ها به فارسی محاوره‌ای کوتاه می‌شوند — «اعمال کن»، «بی‌خیال».

// ❌ غلط — تغییر لحن؛ محاوره‌ای در موبایل، رسمی در دسکتاپ
<Button className="sm:hidden">اعمال کن</Button>

// ✅ درست — کوتاه‌سازی با حذف واژه، نه حذف رسمیت
<Button className="sm:hidden">اعمال فیلترها</Button>

// ✅ درست — یا فقط آیکون با برچسب دسترس‌پذیر کامل
<Button size="icon" className="sm:hidden" aria-label="اعمال فیلترها">
  <Check />
</Button>

چرا دردسرساز است: قانون محتوایی سیستم طراحی، فارسی رسمی در همهٔ متن‌های محصول است («استفاده کنید» نه «استفاده کن») و breakpoint این قانون را تغییر نمی‌دهد. کاربری که در دسکتاپ لحن رسمی و در موبایل لحن محاوره‌ای می‌بیند، محصول را ناسازگار و غیرحرفه‌ای حس می‌کند. کوتاه‌سازی درست از حذف واژه‌های قابل حذف یا استفاده از آیکون با aria-label کامل به دست می‌آید، نه از تغییر register زبانی.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • صفحهٔ جدول داده — اگر جدول ریسپانسیو شما بخشی از یک صفحهٔ لیست کامل است (جستجو، فیلتر، صفحه‌بندی سرور)، الگوی کامل آن صفحه — از state تا حالت خالی — آن‌جا آمده است.
  • ترکیب داشبورد — اگر شبکهٔ ۱/۲/۴ ستونهٔ الگوی ۱ را برای ساخت یک داشبورد کامل سوشال لیسنینگ می‌خواهید، ترتیب و ترکیب بخش‌ها را از آن الگو بگیرید.
  • مودالیتی — اگر مطمئن نیستید فیلترهای موبایل باید در Drawer باز شوند یا Sheet یا Dialog، راهنمای انتخاب بین سطوح مودال آن‌جاست.
  • پوسته برنامه (AppShell) — اگر نمی‌خواهید سایدبار ریسپانسیو الگوی ۲ را دستی سرهم کنید، نسخهٔ آماده و رسمی همین چیدمان را استفاده کنید.

فیلترِ قابل‌تغییر، `FilterChip` نیست

FilterChip یک <span> غیرتعاملی است: label اجباری دارد، فرزندانش را نادیده می‌گیرد، و cursor-default است. نسخهٔ پیشین این صفحه آن را با active و onClick به‌عنوان toggle به کار می‌برد — نتیجه چیپ‌های کاملاً خالی بود که با صفحه‌کلید هم دسترس‌پذیر نبودند. برای انتخاب چندگانه ToggleGroup با type="multiple" را به کار ببرید؛ FilterChip برای نمایش فیلترِ اعمال‌شده با دکمهٔ حذف (label + onRemove) است.