CSS Methodology
CPR follows a strict methodology for where and how CSS is written. The goal is predictability: every developer should know exactly where to look for a style rule and how to add a new one.
Tailwind Layers
All global CSS lives in app/assets/css/main.css and is organized using Tailwind's @layer directive. Layers control specificity and purge behavior.
@layer base -- Global defaults
Used only for element-level defaults that apply everywhere. CPR defines exactly one base rule:
@layer base {
body {
@apply bg-gray-50 text-gray-900;
}
}When to add to @layer base: Almost never. Only for global element resets (e.g., setting a default font or link color site-wide). Prefer component-level styling in all other cases.
@layer components -- Reusable class patterns
Contains classes that represent a complete visual component. These are available globally and can be overridden by utilities.
@layer components {
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-lg
hover:bg-primary-700 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 px-4 py-2 rounded-lg
hover:bg-gray-300 transition-colors;
}
.input-field {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg
focus:ring-2 focus:ring-primary-500 focus:border-transparent
outline-none transition-all;
}
.card {
@apply bg-white rounded-lg shadow-md p-6;
}
}When to add to @layer components: When you have a utility combination that is used in 3+ places across different components and cannot be extracted into a shared Vue component. Prefer Vue components over CSS component classes when the pattern includes structure (HTML) in addition to styling.
Non-layer CSS -- Escape hatch
CSS that sits outside @layer has higher specificity than layered styles. CPR uses this only for pseudo-element styling that Tailwind cannot express:
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}When to use non-layer CSS: Only when targeting pseudo-elements (::-webkit-scrollbar, ::placeholder, ::selection) or when you need a CSS feature that Tailwind does not support as a utility.
When to Use @apply vs Inline Utilities
| Scenario | Approach | Example |
|---|---|---|
| Styling a single element in a template | Inline utilities | <div class="bg-white rounded-xl p-4"> |
| A class used in 3+ templates | @apply in @layer components | .card { @apply bg-white rounded-lg shadow-md p-6; } |
| Variant logic (size, color, state) | Computed class string in <script setup> | See UiButton example below |
| Pseudo-element styling | Raw CSS (non-layer) | .custom-scrollbar::-webkit-scrollbar { ... } |
Computed class strings
When a component has variants, build the class string in JavaScript rather than duplicating utilities across conditional template expressions:
<script setup lang="ts">
const props = defineProps<{
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}>()
const classes = computed(() => {
const base = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2'
const variants = {
primary: 'bg-[#117ea7] text-white hover:bg-[#0e6b8f] focus:ring-[#117ea7]/50',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
}
return [
base,
variants[props.variant ?? 'primary'],
sizes[props.size ?? 'md'],
props.disabled && 'opacity-50 cursor-not-allowed',
]
})
</script>
<template>
<button :class="classes" :disabled="disabled">
<slot />
</button>
</template>Scoped Styles in Components
CPR components generally do not use <style scoped>. Before adding one, check if the need can be met by:
- Tailwind utilities in the template
- A computed class string
- A
@layer componentsclass inmain.css
If none of those work -- typically because you need a complex CSS selector, a keyframe animation, or deep pseudo-element targeting -- then <style scoped> is acceptable:
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>Keep scoped style blocks as small as possible. Do not duplicate what Tailwind already provides.
Decision Tree
Use this when deciding where to put a new style:
Is it a one-off style on a single element?
YES --> Inline Tailwind utilities in the template
NO --> Is it a multi-variant component (size, color, state)?
YES --> Computed class string in <script setup>
NO --> Is the same class combination used in 3+ places?
YES --> @layer components class in main.css
NO --> Is it a pseudo-element or CSS feature Tailwind can't express?
YES --> Non-layer CSS in main.css (or <style scoped>)
NO --> Inline Tailwind utilities