Skip to content

Design System

Version: 1.1.0 | Last Updated: May 2026 Stack: React + TypeScript, Tailwind CSS v4, shadcn/ui (Radix UI primitives), GSAP + ScrollTrigger, Three.js / WebGL, Lucide icons Reference: Aether Architecture design system (Neuform community)

This document is the single source of truth for visual design and front-end implementation decisions across the Shanvi Travels platform. It covers both the public-facing marketing site and the internal agent dashboard.


Shanvi Travels is a premium, trust-first travel agency focused on serving primarily Nepali and South Asian travellers. The visual language communicates:

  • Trustworthy — Clean whites, navy blues, and precise layouts convey reliability.
  • Premium — Glassmorphism surfaces, strong shadows, and bold typography signal quality.
  • Approachable — Rounded corners, warm photography, and readable fonts keep things human.
  • Efficient — Dashboard components prioritise density and scanability for agent workflows.
SurfaceAudienceTone
Marketing SiteTravellersAspirational, warm, immersive imagery
Agent DashboardInternal agents / consultanciesDense, functional, data-forward

The same token system and component library supports both, but usage patterns differ.

  • White space is intentional — never fill space just to fill it.
  • The brand gradient (blue → cyan) is used for hero backgrounds and CTAs, never in body text.
  • Photography always has a dark overlay to ensure text readability.
  • The primary dark navy #001f3f is used for the highest-emphasis dashboard actions.

TokenHexUsage
--color-brand-blue#2563ebPrimary interactive elements, links, focus rings
--color-brand-cyan#06b6d4Gradient endpoint, accent highlights
brand-navy#1e3a8aActive tabs, primary CTAs, selected states
deep-navy#001f3fDashboard headings, modal panels, bulk-action bars

Brand Gradient:

--brand-gradient: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);

Used on hero section overlays and promotional banners. Never on text.

TokenValueUsage
--background#ffffffPage background
--foregroundoklch(0.145 0 0)#0a0a0fPrimary text
--card#ffffffCard surface
--primary#030213Default button fill, primary actions
--primary-foreground#ffffffText on primary buttons
--secondaryoklch(0.95 0.0058 264.53)#f0f0f7Secondary button fills
--muted#ececf0Muted backgrounds, disabled surfaces
--muted-foreground#717182Placeholder, helper, caption text
--accent#e9ebefHover backgrounds, subtle highlights
--destructive#d4183dError states, delete actions
--borderrgba(0,0,0,0.1)All borders
--input-background#f3f3f5Input field fills
--ringoklch(0.708 0 0)Focus ring color
StatusBackgroundTextBorder
Active / Successbg-emerald-50text-emerald-600border-emerald-100
Error / Rejectedbg-red-50text-red-600border-red-100
In Progressbg-blue-50text-blue-600border-blue-100
Paidbg-green-50text-green-600border-green-100
Pending / Neutralbg-gray-50text-gray-400border-gray-100
TokenValue
--chart-1oklch(0.646 0.222 41.116) — orange
--chart-2oklch(0.6 0.118 184.704) — teal
--chart-3oklch(0.398 0.07 227.392) — slate blue
--chart-4oklch(0.828 0.189 84.429) — yellow
--chart-5oklch(0.769 0.188 70.08) — amber

Primary: Inter, system-ui, -apple-system, sans-serif

Base font size: 16px | Font smoothing: antialiased (webkit + moz)

ElementSizeWeightLine Height
h1text-2xl (1.5rem)5001.5
h2text-xl (1.25rem)5001.5
h3text-lg (1.125rem)5001.5
h4text-base (1rem)5001.5
ptext-base (1rem)4001.5
labeltext-base (1rem)5001.5
buttontext-base (1rem)5001.5
inputtext-base (1rem)4001.5

Hero headline:

text-[4.5rem] font-bold tracking-wide leading-[1.1] text-[#edeeef]

Responsive: text-4xltext-5xl (sm) → text-[4.5rem] (lg+)

Dashboard section heading:

text-2xl font-black text-[#001f3f] italic tracking-tight uppercase

Table column header:

text-[10px] font-black text-gray-400 uppercase tracking-tight

Table primary data (name):

text-[11px] font-bold text-blue-900

Table secondary data:

text-[11px] font-medium text-gray-500

Micro-label (above input fields):

text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-0.5

Status badge text:

text-[10px] font-black uppercase

Base unit: 4px (Tailwind 1 = 4px).

ClassValueUsage
gap-1 / p-14pxTight icon spacing
gap-2 / p-28pxInline element gaps
gap-3 / p-312pxSmall component padding
gap-4 / p-416pxStandard cell padding
gap-5 / p-520pxCard internal padding (compact)
gap-6 / p-624pxCard standard padding
gap-8 / p-832pxSection gutters
max-w-7xl mx-auto1280px maxPrimary content container

Section padding:

px-4 sm:px-6 lg:px-8 ← horizontal
py-12 sm:py-16 ← vertical (marketing sections)

Page container:

max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
ComponentGrid
Footergrid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8
Featured Destinationsgrid-cols-1 sm:grid-cols-2 lg:grid-cols-3
Thai Visa Search Bargrid-cols-1 md:grid-cols-4 gap-2
Payment form fieldsgrid-cols-1 sm:grid-cols-2 gap-8

Flight search widget: flex flex-col lg:flex-row — stacked on mobile, single row on desktop.

Tab content stacking (avoids layout shift):

<div class="grid">
<div class="col-start-1 row-start-1 ...">Tab A</div>
<div class="col-start-1 row-start-1 opacity-0 invisible pointer-events-none">
Tab B
</div>
</div>

Tokens live in two files:

FilePurpose
src/index.cssBrand colors, glassmorphism vars, custom animations
src/styles/globals.cssshadcn/ui semantic tokens, dark mode, typography base
TokenValueUsage
--radius0.625rem (10px)Base
--radius-sm6pxSmall elements
--radius-md8pxStandard rounded-md
--radius-lg10pxCards, dialogs
--radius-xl14pxLarge cards

Custom radii (not via token):

  • rounded-xl (12px) — dropdowns, search fields
  • rounded-2xl (16px) — filter bars, visa search
  • rounded-3xl (24px) — main search box, date picker
  • rounded-[2rem] (32px) — VFS table container
  • rounded-[2.5rem] (40px) — payment modal
  • rounded-full — pill buttons, social icons, status pills
--glass-bg: rgba(255, 255, 255, 0.7) --glass-border: rgba(255, 255, 255, 0.2);

Utility classes:

.glass { bg-white/70 backdrop-blur-md border border-white/20 }
.glass-dark { bg-black/40 backdrop-blur-md border border-white/10 }

The project uses shadcn/ui (Radix UI primitives + Tailwind). Components live in src/components/ui/.

VariantVisualUsage
defaultbg-primary text-whiteDefault action
destructivebg-destructive text-whiteDelete, danger
outlineborder bg-backgroundSecondary option
secondarybg-secondaryLower-emphasis
ghostHover bg onlyToolbar/icon buttons
linkUnderline on hoverInline navigation
SizeHeightPadding
defaulth-9px-4 py-2
smh-8px-3
lgh-10px-6
iconsize-9Square

Pill CTA button (used on search/submit):

px-6 py-2.5 bg-[#1e3a8a] hover:bg-[#152a66] text-white rounded-full
font-bold text-xs uppercase tracking-wider transition-all active:scale-95 shadow-md

Premium shimmer button (.btn-premium):

.btn-premium {
@apply relative overflow-hidden transition-all duration-300 active:scale-95;
}
.btn-premium::after {
content: "";
@apply absolute inset-0 bg-white/20 translate-y-full transition-transform duration-300;
}
.btn-premium:hover::after {
@apply translate-y-0;
}

Rules: One primary button per section. Always active:scale-95. rounded-full reserved for CTAs and social icons.

Standard input:

h-9 w-full rounded-md border px-3 py-1 bg-input-background
focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
aria-invalid:border-destructive

Custom radio button:

<div
className={`w-4 h-4 rounded-full border flex items-center justify-center transition-all
${selected ? "border-blue-600 bg-blue-600" : "border-slate-300 bg-white"}`}
>
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>

Custom checkbox:

<button
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all
${checked ? "bg-blue-600 border-blue-600 text-white" : "border-gray-200 bg-white hover:border-blue-400"}`}
>
{checked && <Check size={12} strokeWidth={4} />}
</button>

File upload zone:

flex flex-col items-center justify-center w-full h-32
border-2 border-dashed border-gray-200 bg-white rounded-2xl
cursor-pointer hover:border-blue-400 transition-all

Active state: border-green-300 bg-green-50

<Card> {/* bg-card rounded-xl border flex flex-col gap-6 */}
<CardHeader> {/* px-6 pt-6 grid auto-rows-min gap-1.5 */}
<CardContent> {/* px-6 */}
<CardFooter> {/* px-6 pb-6 flex items-center */}
</Card>

Dashboard table container:

bg-white rounded-[2rem] shadow-sm border border-gray-100 overflow-hidden

Payment modal dark panel:

md:w-80 bg-[#001f3f] p-8 text-white

Status pill:

inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase border

Colors follow the Status Colors table.

IATA code tag:

px-2 py-0.5 bg-slate-100 text-[#1e3a8a] font-black text-[10px] rounded
<div className="bg-white rounded-[2rem] shadow-sm border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50/50 border-b border-gray-100">
<tr>
<th className="px-5 py-5 text-[10px] font-black text-gray-400 uppercase tracking-tight whitespace-nowrap">
Header
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tr className="hover:bg-blue-50/5 transition-colors text-[11px]">
<td className="px-5 py-4 whitespace-nowrap font-medium text-gray-500">
Data
</td>
</tr>
</tbody>
</table>
</div>
</div>

Row hover: hover:bg-blue-50/5 | Selected row: bg-blue-50/30 | Always overflow-x-auto.

Tab bar (search widget):

<button
className={`flex items-center gap-2 py-3 px-1 font-bold text-sm tracking-wide relative
${active ? "text-[#1e3a8a]" : "text-slate-500 hover:text-slate-800"}`}
>
{active && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#1e3a8a]" />
)}
</button>

Container: flex items-center gap-6 border-b border-slate-100 pb-2 mb-4 overflow-x-auto

Active tab uses a 2px bottom border, not an underline. Tabs never wrap.

Backdrop: fixed inset-0 bg-[#001f3f]/80 backdrop-blur-sm

Modal container:

bg-white w-full max-w-4xl rounded-[2.5rem] shadow-2xl overflow-hidden
animate-modal-enter flex flex-col md:flex-row

Rules: z-[100] for modals, z-[99] for dropdowns. Dark panel always left at md:w-80. Click backdrop to close.

absolute top-[105%] left-0 bg-white rounded-xl shadow-2xl border border-slate-100 p-3 z-[99]
animate-in fade-in slide-in-from-top-1 duration-200

Items: p-2.5 hover:bg-slate-50 cursor-pointer rounded-lg flex justify-between items-center Max scroll height: max-h-52 overflow-y-auto Click-outside via mousedown + contains(). Inner clicks use e.stopPropagation().

Uses Sonner. Severity rules:

  • toast.error() — validation failures
  • toast.success() — completions
  • toast.info() — in-progress actions (sparingly add emoji, e.g. ✈️)

LevelStyleUsage
Hero overlaybg-gradient-to-r from-black/75 via-black/40 to-transparent backdrop-blur-[0.5px]Full-bleed hero readability
Glass card.glass = bg-white/70 backdrop-blur-md border border-white/20Floating over imagery
Glass dark.glass-dark = bg-black/40 backdrop-blur-md border border-white/10Dark overlays

Search box (pure white for form legibility):

bg-white rounded-3xl shadow-[0_15px_45px_rgba(0,0,0,0.25)] p-5 md:p-6

Dark panel card surface (Aether-adapted):

background: rgba(12, 74, 110, 0.8);
backdrop-filter: blur(12px);
padding: 24px;
/* Use rounded-3xl for Shanvi — Aether uses 0px */

Gradient border shell technique:

<!-- Outer shell: 1px padding + gradient = premium depth edge -->
<div
style="padding: 1px; background: linear-gradient(to right, rgba(8,47,73,0.9), rgba(7,89,133,0.5), rgba(0,0,0,0)); border-radius: 1rem;"
>
<!-- Inner surface -->
<div
style="background: rgba(12,74,110,0.8); backdrop-filter: blur(12px); border-radius: calc(1rem - 1px); padding: 24px;"
>
<!-- content -->
</div>
</div>

Use on hero panels, featured destination cards, and premium modal surfaces.


LevelValueUsage
FlatnoneTable separators, inline elements
Subtle0px 1px 2px rgba(0,0,0,0.05)Input fields, secondary cards
Standardshadow-mdRegular cards, buttons
Elevated0px 15px 45px rgba(0,0,0,0.25)Search box over hero
Deep0px 20px 50px rgba(0,0,0,0.2)Date picker, large popovers
Glow0px 0px 12px rgba(12,74,110,0.5)Premium glass cards
Bulk barshadow-2xl shadow-blue-900/40Floating action bar

Blur levels:

UsageBlur
Glass cards / overlaysbackdrop-blur-md (12px)
Modal backdropbackdrop-blur-sm (4px)
Hero micro-blurbackdrop-blur-[0.5px]

Rules:

  1. Never mix more than two shadow recipes in one component.
  2. Gradient border shell replaces a visible stroke — don’t add both.
  3. Never blur plain coloured backgrounds — only surfaces over imagery or other content.

Primary library: Lucide React (import individually for tree-shaking).

ContextSize
Tab iconssize={16}
Field prefix iconssize={18}
Dropdown searchsize={15}
Table actionsize={14}
Button iconssize={14}size={16}
Social / contact iconsw-5 h-5
Modal closesize={20}

Icon colours:

  • Decorative: text-slate-400
  • Active/branded: text-[#1e3a8a] or text-blue-600
  • Error: text-red-500 | Success: text-emerald-600 | Info: text-blue-400

Special: Plane icon in flight tab uses transform -rotate-45. Loading spinner is a CSS border trick (border-t-transparent animate-spin), not an icon.

Photography rules:

  • Hero: full-bleed Unsplash, object-cover, always with dark overlay.
  • Logo on dark bg: filter: brightness(0) invert(1).
  • Maps: Google Maps embed in rounded-2xl overflow-hidden h-40.

DurationEasingUsage
150mseaseColour changes, icon swaps
200mscubic-bezier(0.4, 0, 0.2, 1)Dropdown open, tab switch
250msease-outFade in, modal enter
300mseaseStandard hover transitions
350mscubic-bezier(0.16, 1, 0.3, 1)Slide-in panels
1000ms+power3.out (GSAP)Hero entrance sequences
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.fromTo(
textRef.current,
{ y: 60, opacity: 0 },
{ y: 0, opacity: 1, duration: 1.2 },
);
tl.fromTo(
searchRef.current,
{ y: 40, opacity: 0 },
{ y: 0, opacity: 1, duration: 1 },
"-=0.9",
);

Slide in from right (drawer):

@keyframes slideInFromRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in {
animation: slideInFromRight 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

Fade in:

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.25s ease-out forwards;
}

Modal enter:

@keyframes modalEnter {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.animate-modal-enter {
animation: modalEnter 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

Transition rules:

  • active:scale-95 on every clickable element.
  • transition-colors for colour-only changes (not transition-all).
  • transition-[color,box-shadow] on form inputs.
  • hover:scale-110 on social icons only.

The hero section background is a full-bleed Three.js canvas — a perspective grid field with cool monochrome tones, depth fade, slow breathing pulse, and subtle pointer parallax.

<canvas id="webgl" class="fixed inset-0 w-full h-full z-0"></canvas>
<div class="hero-fallback-bg" style="display:none;"><!-- fallback bg --></div>
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // DPR clamp
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f9ff); // sky-50
scene.fog = new THREE.FogExp2(0xf0f9ff, 0.025);
const camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 1000);
// Lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
scene.add(new THREE.DirectionalLight(0x38bdf8, 1.0));
// Material
const mat = new THREE.MeshStandardMaterial({ color: 0x38bdf8 });
  • Lines in rgba(56,189,248,0.3) on #F0F9FF background.
  • FogExp2 fades distant lines naturally.
  • Breathing pulse: Y-axis oscillation ±5 units over 4–6s using Math.sin(time).
document.addEventListener("mousemove", (e) => {
const x = (e.clientX / window.innerWidth - 0.5) * 2;
const y = (e.clientY / window.innerHeight - 0.5) * 2;
camera.position.x += (x * 3 - camera.position.x) * 0.02; // lerp
camera.position.y += (-y * 2 - camera.position.y) * 0.02;
camera.lookAt(scene.position);
});
if (!window.WebGLRenderingContext) {
document.getElementById("webgl").style.display = "none";
document.querySelector(".hero-fallback-bg").style.display = "block";
}

Integration notes:

  • Canvas at z-0, hero content at z-20+.
  • Dark overlay still applies on top of canvas.
  • On mobile: cap DPR at 1.5.
  • Use GSAP ScrollTrigger to fade canvas out as user scrolls past the hero.

BreakpointMin WidthPrefix
Default (mobile)0px
sm640pxsm:
md768pxmd:
lg1024pxlg:
xl1280pxxl:
2xl1536px2xl:
ComponentMobileDesktop
Heromin-h-0 py-16lg:h-[80vh] lg:min-h-[620px] lg:py-0
Search fieldsflex-col, border-blg:flex-row, lg:border-r
Date pickerSingle month, w-[92vw]Two months, md:w-[640px]
Footergrid-cols-1lg:grid-cols-4
Modalsflex-colmd:flex-row

Minimum touch target: 40×40px. Prefer h-10 buttons on mobile.


  • Focus rings: focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] on all interactive elements.
  • ARIA: Label all inputs. Modals get role="dialog" aria-modal="true" aria-labelledby. Loading containers get aria-busy="true".
  • Disabled: disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
  • Error inputs: aria-invalid="true" triggers aria-invalid:ring-destructive/20 aria-invalid:border-destructive

Contrast minimums (WCAG AA):

  • text-slate-500 (#64748b) on white = 4.6:1 ✅
  • text-slate-400 (#94a3b8) on white = 2.5:1 — placeholders/labels only
  • #1e3a8a on white = high contrast ✅

Reduced motion:

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

Implemented via .dark class on <html> (not prefers-color-scheme). Toggle not yet wired to a UI control — pending feature.

TokenLightDark
--background#ffffff#0d0d12
--foreground#0a0a0f#fafaff
--primary#030213oklch(0.985 0 0)
--muted#ececf0oklch(0.269 0 0)
--borderrgba(0,0,0,0.1)oklch(0.269 0 0)
--sidebaroklch(0.985 0 0)oklch(0.205 0 0)

To implement: ThemeToggle button in dashboard header toggles .dark on <html> and persists to localStorage.


<Navbar /> ← Sticky top nav
<Hero /> ← 80vh full-bleed, WebGL bg, search widget
<Services /> ← Icon + description cards
<FeaturedDestinations /> ← Image cards with hover overlay
<StopoverPackages /> ← Package cards
<Testimonials /> ← Star ratings + quotes
<Team /> ← Team member cards
<Footer /> ← 4-column grid with map
<Sidebar /> ← shadcn/ui sidebar, collapsible
<main>
<DashboardHeader /> ← User info, notifications
<TabView> ← Tickets | VFS | Settings
<VfsTrackingFlow /> ← 8-stage pipeline with counts
<VfsFilterBar /> ← Search + multi-select filters
<PageHeader /> ← Title + Export CSV button
<VfsList /> ← Data table
← Bulk action bar (fixed bottom)
← Payment modal (full-screen overlay)

Bulk action bar (fixed bottom, appears on row selection):

fixed bottom-8 left-1/2 -translate-x-1/2 bg-[#001f3f] text-white
px-8 py-4 rounded-3xl shadow-2xl shadow-blue-900/40
flex items-center gap-10 z-50 border border-blue-400/20 backdrop-blur-xl

Hardcoded Brand Values (Approved Exceptions)

Section titled “Hardcoded Brand Values (Approved Exceptions)”
HexIntended TokenUsage
#1e3a8abrand-navyActive states, primary CTAs
#152a66brand-navy-darkHover on brand-navy
#001f3fdeep-navyDashboard headings, modal panels
#edeeefhero-textHero headline text

These should be moved to @theme tokens in index.css during the rebuild.

✅ Do❌ Don’t
Use active:scale-95 on all clickable elementsUse transform: scale() without transition
Use transition-colors for colour-only changesUse transition-all when only colour changes
Wrap tables in overflow-x-autoLet tables overflow the viewport
Use semantic status colours from the token systemHardcode raw hex for status colours
Apply whitespace-nowrap to table cellsLet table cells wrap unexpectedly
Use z-[99] dropdowns / z-[100] modalsMix z-index values inconsistently
Keep spacing on the 4px rhythmIntroduce arbitrary spacing values
Glass surface first; shadow/blur reinforceUse shadows to fake depth without glass material
Provide DOM fallback for WebGL canvasRender Three.js without a fallback
Keep motion at 150–200ms for micro-interactionsExceed moderate motion without deliberate reason
LibraryUsage
gsap + ScrollTriggerHero entrance + scroll-triggered reveals
three (r128+)WebGL background canvas
lucide-reactAll icons
sonnerToast notifications
react-router-domClient-side routing
@radix-ui/react-*Accessible UI primitives (via shadcn)
class-variance-authorityButton/Badge variant system
clsx + tailwind-mergeClass merging via cn()